@ifc-lite/viewer 1.29.0 → 1.30.0

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.
Files changed (80) hide show
  1. package/.turbo/turbo-build.log +32 -31
  2. package/CHANGELOG.md +58 -0
  3. package/dist/assets/{basketViewActivator-BSRgF1Hw.js → basketViewActivator-BHNb23Vw.js} +6 -6
  4. package/dist/assets/{bcf-3uE1MvcT.js → bcf-C0XZ2DSl.js} +1 -1
  5. package/dist/assets/{deflate-BLlUfw9-.js → deflate-DvvFcUtV.js} +1 -1
  6. package/dist/assets/{exporters-BL6UmxRa.js → exporters-CvORJOLn.js} +1345 -1434
  7. package/dist/assets/geometry.worker-B7X9DQQY.js +1 -0
  8. package/dist/assets/{geotiff-BydcIud8.js → geotiff-fSD_sVw_.js} +10 -10
  9. package/dist/assets/{ids-DFl74rTt.js → ids-BUOe5QQl.js} +951 -713
  10. package/dist/assets/idsValidation.worker-DEodXb0f.js +190468 -0
  11. package/dist/assets/ifc-lite_bg-CmMuB1zf.wasm +0 -0
  12. package/dist/assets/{index-BNTlm2lP.js → index-B6T42T86.js} +35235 -32937
  13. package/dist/assets/index-D0tqJL0X.css +1 -0
  14. package/dist/assets/{index.es-Bk4nLsyS.js → index.es-YGMensDM.js} +7 -7
  15. package/dist/assets/{jpeg-BvMO8-Tc.js → jpeg-0Sla88_N.js} +1 -1
  16. package/dist/assets/{jspdf.es.min-BZ_ed66E.js → jspdf.es.min-mnbLNj-p.js} +4 -4
  17. package/dist/assets/{lerc-CNnDpLpV.js → lerc-C7xUDHpL.js} +1 -1
  18. package/dist/assets/{lzw-DBaPrGGZ.js → lzw-CK480t0_.js} +1 -1
  19. package/dist/assets/{native-bridge-DFOoBvTg.js → native-bridge-sLWRanza.js} +1 -1
  20. package/dist/assets/{packbits-C7uyD2Bi.js → packbits-DcL4imYS.js} +1 -1
  21. package/dist/assets/parser.worker-BsGV6ml7.js +182 -0
  22. package/dist/assets/{pdf-DlqdjX9e.js → pdf-BARGfLmx.js} +8 -8
  23. package/dist/assets/raw-BMWh6mDy.js +1 -0
  24. package/dist/assets/{sandbox-0Z2NzeOJ.js → sandbox-BSiO04m8.js} +2801 -2609
  25. package/dist/assets/server-client-AlpWMVq9.js +741 -0
  26. package/dist/assets/{webimage-zN-oCabb.js → webimage-uy5DjZLk.js} +1 -1
  27. package/dist/assets/{xlsx-N2LbIR1G.js → xlsx-D02ho69_.js} +6 -6
  28. package/dist/assets/{zstd-Jk3QKIeb.js → zstd-DcR1TBwT.js} +1 -1
  29. package/dist/index.html +7 -7
  30. package/package.json +27 -32
  31. package/src/components/viewer/CesiumOverlay.tsx +195 -8
  32. package/src/components/viewer/IDSPanel.tsx +23 -7
  33. package/src/components/viewer/MainToolbar.tsx +31 -0
  34. package/src/components/viewer/SunSkyPanel.tsx +363 -0
  35. package/src/components/viewer/Viewport.tsx +49 -1
  36. package/src/components/viewer/ViewportContainer.tsx +20 -1
  37. package/src/components/viewer/useAnimationLoop.ts +19 -1
  38. package/src/disable-react-dev-perf-track.ts +38 -0
  39. package/src/hooks/has-entity-type.ts +33 -0
  40. package/src/hooks/ids/idsWorkerClient.ts +136 -0
  41. package/src/hooks/ingest/federationAlign.ts +1 -1
  42. package/src/hooks/ingest/pointCloudIngest.ts +22 -98
  43. package/src/hooks/ingest/viewerModelIngest.ts +8 -13
  44. package/src/hooks/useAlignmentLines3D.ts +5 -0
  45. package/src/hooks/useGridLines3D.ts +4 -0
  46. package/src/hooks/useIDS.ts +77 -13
  47. package/src/hooks/useIfcCache.ts +1 -1
  48. package/src/hooks/useIfcFederation.ts +1 -1
  49. package/src/hooks/useIfcServer.ts +1 -1
  50. package/src/hooks/useModelSelection.ts +1 -1
  51. package/src/hooks/useSolarEnvironment.ts +114 -0
  52. package/src/hooks/useSolarSweep.ts +66 -0
  53. package/src/hooks/useSymbolicAnnotations.ts +10 -0
  54. package/src/hooks/useViewerSelectors.ts +1 -1
  55. package/src/lib/geo/cesium-sun.ts +277 -0
  56. package/src/lib/geo/solar-direction.test.ts +70 -0
  57. package/src/lib/geo/solar-direction.ts +94 -0
  58. package/src/lib/lighting-presets.ts +128 -0
  59. package/src/lib/recent-files.ts +4 -24
  60. package/src/lib/solar-time.ts +55 -0
  61. package/src/main.tsx +5 -0
  62. package/src/store/index.ts +8 -0
  63. package/src/store/slices/annotationsSlice.test.ts +0 -16
  64. package/src/store/slices/cesiumSlice.ts +3 -3
  65. package/src/store/slices/dataSlice.test.ts +0 -40
  66. package/src/store/slices/environmentSlice.ts +101 -0
  67. package/src/store/slices/idsSlice.ts +6 -1
  68. package/src/store/slices/selectionSlice.test.ts +0 -43
  69. package/src/store/slices/solarSlice.ts +121 -0
  70. package/src/store/slices/visibilitySlice.test.ts +15 -45
  71. package/src/utils/loadingUtils.ts +1 -1
  72. package/src/workers/idsValidation.worker.ts +98 -0
  73. package/dist/assets/geometry.worker-DVwFYHTq.js +0 -1
  74. package/dist/assets/ifc-lite_bg-FPffpFK_.wasm +0 -0
  75. package/dist/assets/index-DpoJvkdg.css +0 -1
  76. package/dist/assets/parser.worker-U_PVhLNi.js +0 -182
  77. package/dist/assets/raw-p_2cfl6T.js +0 -1
  78. package/dist/assets/server-client-DUMy2mXg.js +0 -719
  79. package/src/components/ui/context-menu.tsx +0 -174
  80. package/src/store.ts +0 -80
@@ -0,0 +1,66 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Solar sweep animation — advances the studied instant while playing.
7
+ *
8
+ * Lives at the viewport level (not inside the panel) so collapsing or
9
+ * closing the Sun & Sky panel doesn't stop a running sweep.
10
+ *
11
+ * Reads/writes the store imperatively each tick to avoid stale closures,
12
+ * and wraps within the current day (day sweep) or year (year sweep) so the
13
+ * animation loops cleanly.
14
+ */
15
+
16
+ import { useEffect, useRef } from 'react';
17
+ import { useViewerStore } from '@/store';
18
+ import { solarDisplayOffsetMinutes, MS_PER_MIN, MS_PER_DAY } from '@/lib/solar-time';
19
+
20
+ /** Wall-clock cadence for the animation sweep. */
21
+ const TICK_MS = 80;
22
+ /** Per-tick advance: 8 minutes of solar time (day) or 2 days (year). */
23
+ const DAY_STEP_MIN = 8;
24
+ const YEAR_STEP_DAYS = 2;
25
+
26
+ export function useSolarSweep(): void {
27
+ const enabled = useViewerStore((s) => s.solarEnabled);
28
+ const playing = useViewerStore((s) => s.solarPlaying);
29
+ const sweepMode = useViewerStore((s) => s.solarSweepMode);
30
+
31
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
32
+ useEffect(() => {
33
+ if (!enabled || !playing) return;
34
+ intervalRef.current = setInterval(() => {
35
+ const store = useViewerStore.getState();
36
+ const current = store.solarDateMs;
37
+ let next: number;
38
+ if (store.solarSweepMode === 'day') {
39
+ // Wrap in the same display frame the slider edits in, so the day seam
40
+ // lands at displayed midnight (not UTC midnight) and the date doesn't
41
+ // flip mid-sweep when site/local time is active.
42
+ const offsetMin = solarDisplayOffsetMinutes(store.solarUseLocalTime, store.solarSunInfo?.longitude);
43
+ const displayMs = current + offsetMin * MS_PER_MIN;
44
+ const disp = new Date(displayMs);
45
+ const dayStart = Date.UTC(disp.getUTCFullYear(), disp.getUTCMonth(), disp.getUTCDate());
46
+ const minutes = (displayMs - dayStart) / MS_PER_MIN;
47
+ const nextMinutes = (minutes + DAY_STEP_MIN) % 1440;
48
+ next = dayStart + nextMinutes * MS_PER_MIN - offsetMin * MS_PER_MIN;
49
+ } else {
50
+ const d = new Date(current);
51
+ const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1);
52
+ const dayOfYear = Math.floor((current - yearStart) / MS_PER_DAY);
53
+ const isLeap = (d.getUTCFullYear() % 4 === 0 && d.getUTCFullYear() % 100 !== 0) || d.getUTCFullYear() % 400 === 0;
54
+ const daysInYear = isLeap ? 366 : 365;
55
+ const timeOfDay = current - (yearStart + dayOfYear * MS_PER_DAY);
56
+ const nextDay = (dayOfYear + YEAR_STEP_DAYS) % daysInYear;
57
+ next = yearStart + nextDay * MS_PER_DAY + timeOfDay;
58
+ }
59
+ store.setSolarDateMs(next);
60
+ }, TICK_MS);
61
+ return () => {
62
+ if (intervalRef.current !== null) clearInterval(intervalRef.current);
63
+ intervalRef.current = null;
64
+ };
65
+ }, [enabled, playing, sweepMode]);
66
+ }
@@ -19,6 +19,7 @@ import { decodeIfcString } from '@ifc-lite/encoding';
19
19
  import { useViewerStore } from '@/store';
20
20
  import { useShallow } from 'zustand/react/shallow';
21
21
  import type { IfcDataStore } from '@ifc-lite/parser';
22
+ import { hasEntityType } from './has-entity-type.js';
22
23
 
23
24
  /** Lines belonging to a single storey, ready to feed into the section overlay. */
24
25
  export interface AnnotationsForStorey {
@@ -240,6 +241,15 @@ async function parseAnnotations(
240
241
  if (debugEnabled()) console.log('[annotations] skip: missing/empty source');
241
242
  return result;
242
243
  }
244
+ // Skip the full-source WASM scan only when the model has neither IfcAnnotation
245
+ // nor IfcGridAxis — this parse path ALSO feeds the grid buckets (gridByStorey /
246
+ // gridLoose*), so gating on IfcAnnotation alone would drop grid-only models.
247
+ // The scan copies the entire IFC source into the WASM heap on the main thread,
248
+ // so skipping it when there is nothing to find still matters.
249
+ if (!hasEntityType(store, 'IfcAnnotation', 'IfcGridAxis')) {
250
+ if (debugEnabled()) console.log('[annotations] skip: no IfcAnnotation/IfcGridAxis entities');
251
+ return result;
252
+ }
243
253
 
244
254
  const hierarchy = store.spatialHierarchy;
245
255
  const elementToStorey = hierarchy?.elementToStorey;
@@ -8,7 +8,7 @@
8
8
  * Extracted from Viewport.tsx for reusability
9
9
  */
10
10
 
11
- import { useViewerStore } from '../store.js';
11
+ import { useViewerStore } from '../store/index.js';
12
12
 
13
13
  /**
14
14
  * Selection-related store state and actions
@@ -0,0 +1,277 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Cesium sun helpers — turn the renderer-agnostic geometry from
7
+ * `@ifc-lite/solar` into real Cesium scene state for a georeferenced sun-path
8
+ * + shadow study:
9
+ *
10
+ * • applySolarScene — drive Cesium's sun, lighting and shadow map from a
11
+ * chosen instant (the model + OSM context then cast and
12
+ * receive real shadows).
13
+ * • SunPathDome — a self-contained set of polyline/point entities for
14
+ * the 3D dome (day arc, hourly analemmas, graticule,
15
+ * cardinals, and the live sun marker + beam).
16
+ *
17
+ * All dome geometry is positioned by mapping the solar package's ENU unit
18
+ * vectors through `eastNorthUpToFixedFrame(origin)`, the same ENU→ECEF frame
19
+ * the coordinate bridge uses, so the dome is pinned to the model's true site.
20
+ */
21
+
22
+ import {
23
+ analemmaPaths,
24
+ dayPath,
25
+ domeGraticule,
26
+ azimuthAltitudeToEnu,
27
+ sunPosition,
28
+ type Enu,
29
+ } from '@ifc-lite/solar';
30
+
31
+ type CesiumNs = typeof import('cesium');
32
+ type CesiumViewer = InstanceType<typeof import('cesium').Viewer>;
33
+
34
+ export interface SolarSceneOptions {
35
+ /** Studied instant. */
36
+ date: Date;
37
+ /** Master enable — false restores the neutral (no-sun) overlay state. */
38
+ enabled: boolean;
39
+ /** Whether to render the shadow map (mutual model ↔ context shadows). */
40
+ shadows: boolean;
41
+ /**
42
+ * Show the sun billboard. Off by default (transparent compositing); the
43
+ * environment panel's Sky toggle turns it on together with the atmosphere.
44
+ */
45
+ showSun?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Drive Cesium's sun, lighting and shadow map from the studied instant.
50
+ * Idempotent — safe to call every time the date or toggles change.
51
+ */
52
+ export function applySolarScene(
53
+ Cesium: CesiumNs,
54
+ viewer: CesiumViewer,
55
+ options: SolarSceneOptions,
56
+ ): void {
57
+ const scene = viewer.scene;
58
+ const { enabled, shadows, date, showSun = false } = options;
59
+
60
+ if (!enabled) {
61
+ // Restore the transparent-compositing defaults set up in CesiumOverlay
62
+ // (the sun billboard stays available for the Sky toggle).
63
+ if (scene.sun) scene.sun.show = showSun;
64
+ scene.globe.enableLighting = false;
65
+ viewer.shadows = false;
66
+ scene.light = new Cesium.DirectionalLight({
67
+ direction: new Cesium.Cartesian3(0.5, -1.0, -0.3),
68
+ });
69
+ return;
70
+ }
71
+
72
+ // Pin the simulation clock to the studied instant so Cesium's SunLight and
73
+ // shadow map are computed for exactly this date/time.
74
+ const julian = Cesium.JulianDate.fromDate(date);
75
+ viewer.clock.shouldAnimate = false;
76
+ viewer.clock.currentTime = julian;
77
+ scene.light = new Cesium.SunLight();
78
+ if (scene.sun) scene.sun.show = showSun; // billboard only with the Sky toggle
79
+ scene.globe.enableLighting = true;
80
+ viewer.shadows = shadows;
81
+ if (viewer.shadowMap) {
82
+ viewer.shadowMap.enabled = shadows;
83
+ viewer.shadowMap.softShadows = true;
84
+ viewer.shadowMap.darkness = 0.35;
85
+ }
86
+ scene.requestRender();
87
+ }
88
+
89
+ export interface SunPathDomeOptions {
90
+ /** Site origin (model georef). */
91
+ origin: { longitude: number; latitude: number; height: number };
92
+ /** Dome radius in metres. */
93
+ radius: number;
94
+ /** Studied instant (positions the live sun marker). */
95
+ date: Date;
96
+ /** Show the hourly analemma figure-eights. */
97
+ showAnalemmas?: boolean;
98
+ }
99
+
100
+ const DAY_ARC_COLOR = [255, 200, 40] as const; // warm yellow
101
+ const ANALEMMA_COLOR = [120, 170, 255] as const; // cool blue
102
+ const GRID_COLOR = [255, 255, 255] as const;
103
+ const SUN_COLOR = [255, 230, 120] as const;
104
+
105
+ /**
106
+ * A self-contained collection of Cesium entities forming the 3D sun-path
107
+ * dome. Construct once for a site/date, then call {@link update} as the
108
+ * studied instant changes (only the sun marker + beam move), and
109
+ * {@link destroy} to remove every entity.
110
+ */
111
+ export class SunPathDome {
112
+ private readonly Cesium: CesiumNs;
113
+ private readonly viewer: CesiumViewer;
114
+ private readonly dataSource: InstanceType<typeof import('cesium').CustomDataSource>;
115
+ private readonly options: SunPathDomeOptions;
116
+ private readonly enuToEcef: InstanceType<typeof import('cesium').Matrix4>;
117
+ private sunMarker: InstanceType<typeof import('cesium').Entity> | null = null;
118
+ private sunBeam: InstanceType<typeof import('cesium').Entity> | null = null;
119
+
120
+ /** Build the dome's data source + static geometry and add it to the viewer. */
121
+ constructor(Cesium: CesiumNs, viewer: CesiumViewer, options: SunPathDomeOptions) {
122
+ this.Cesium = Cesium;
123
+ this.viewer = viewer;
124
+ this.options = options;
125
+ this.dataSource = new Cesium.CustomDataSource('ifc-lite-sun-path');
126
+
127
+ const originCart = Cesium.Cartesian3.fromDegrees(
128
+ options.origin.longitude,
129
+ options.origin.latitude,
130
+ options.origin.height,
131
+ );
132
+ this.enuToEcef = Cesium.Transforms.eastNorthUpToFixedFrame(originCart);
133
+
134
+ this.buildStatic();
135
+ this.update(options.date);
136
+ // Await attach, THEN request a render: the viewer runs in requestRenderMode,
137
+ // so a render requested before the data source is attached would paint
138
+ // nothing and the dome would stay invisible until the next camera move.
139
+ viewer.dataSources
140
+ .add(this.dataSource)
141
+ .then(() => {
142
+ viewer.scene.requestRender();
143
+ console.log('[SunPathDome] built', {
144
+ lat: options.origin.latitude,
145
+ lon: options.origin.longitude,
146
+ height: options.origin.height,
147
+ radius: options.radius,
148
+ entities: this.dataSource.entities.values.length,
149
+ });
150
+ })
151
+ .catch((err) => console.warn('[SunPathDome] failed to add data source:', err));
152
+ }
153
+
154
+ /** Map a single ENU direction (unit) at the dome radius to an ECEF position. */
155
+ private enuDirToEcef(dir: Enu, radiusScale = 1): InstanceType<typeof import('cesium').Cartesian3> {
156
+ const r = this.options.radius * radiusScale;
157
+ return this.Cesium.Matrix4.multiplyByPoint(
158
+ this.enuToEcef,
159
+ new this.Cesium.Cartesian3(dir.e * r, dir.n * r, dir.u * r),
160
+ new this.Cesium.Cartesian3(),
161
+ );
162
+ }
163
+
164
+ /** Add a polyline entity from a list of ENU directions at the dome radius. */
165
+ private polyline(
166
+ dirs: Enu[],
167
+ rgb: readonly [number, number, number],
168
+ width: number,
169
+ ): void {
170
+ if (dirs.length < 2) return;
171
+ const positions = dirs.map((d) => this.enuDirToEcef(d));
172
+ this.dataSource.entities.add({
173
+ polyline: {
174
+ positions,
175
+ width,
176
+ material: this.Cesium.Color.fromBytes(rgb[0], rgb[1], rgb[2], 235),
177
+ // Draw the dome even where it sits behind terrain / 3D tiles or the
178
+ // model, dimmer, so the full sun path stays legible in 3D.
179
+ depthFailMaterial: this.Cesium.Color.fromBytes(rgb[0], rgb[1], rgb[2], 90),
180
+ arcType: this.Cesium.ArcType.NONE,
181
+ },
182
+ });
183
+ }
184
+
185
+ /** Build the date-independent geometry: graticule, cardinals, analemmas, day arc. */
186
+ private buildStatic(): void {
187
+ const { origin, date } = this.options;
188
+
189
+ // Graticule — altitude rings + azimuth spokes.
190
+ const grat = domeGraticule({ altitudeStep: 15, azimuthStep: 30 });
191
+ for (const ring of grat.altitudeRings) {
192
+ this.polyline(ring.ring, GRID_COLOR, ring.altitude === 0 ? 2 : 1);
193
+ }
194
+ for (const spoke of grat.azimuthSpokes) {
195
+ this.polyline(spoke.arc, GRID_COLOR, 1);
196
+ }
197
+
198
+ // Cardinal / ordinal labels on the horizon.
199
+ for (const c of grat.cardinals) {
200
+ this.dataSource.entities.add({
201
+ position: this.enuDirToEcef(c.dir, 1.05),
202
+ label: {
203
+ text: c.label,
204
+ font: '600 14px sans-serif',
205
+ fillColor: this.Cesium.Color.WHITE,
206
+ outlineColor: this.Cesium.Color.BLACK,
207
+ outlineWidth: 2,
208
+ style: this.Cesium.LabelStyle.FILL_AND_OUTLINE,
209
+ disableDepthTestDistance: Number.POSITIVE_INFINITY,
210
+ },
211
+ });
212
+ }
213
+
214
+ // Hourly analemmas (figure-eights) across the year.
215
+ if (this.options.showAnalemmas ?? true) {
216
+ const year = date.getUTCFullYear();
217
+ for (const path of analemmaPaths(year, origin.latitude, origin.longitude, { dayStep: 5 })) {
218
+ const above = path.samples.filter((s) => s.aboveHorizon).map((s) => s.dir);
219
+ this.polyline(above, ANALEMMA_COLOR, 1);
220
+ }
221
+ }
222
+
223
+ // Day arc for the studied date.
224
+ const arc = dayPath(date, origin.latitude, origin.longitude, { stepMinutes: 10 });
225
+ this.polyline(arc.map((s) => s.dir), DAY_ARC_COLOR, 2);
226
+ }
227
+
228
+ /** Reposition the live sun marker + beam for a new instant. */
229
+ update(date: Date): void {
230
+ const { origin } = this.options;
231
+ const pos = sunPosition(date, origin.latitude, origin.longitude);
232
+ const dir = azimuthAltitudeToEnu(pos.azimuth, pos.altitude);
233
+ const show = pos.altitude >= 0;
234
+ const sunEcef = this.enuDirToEcef(dir);
235
+ const originEcef = this.enuDirToEcef({ e: 0, n: 0, u: 0 });
236
+
237
+ if (!this.sunMarker) {
238
+ this.sunMarker = this.dataSource.entities.add({
239
+ position: sunEcef,
240
+ point: {
241
+ pixelSize: 16,
242
+ color: this.Cesium.Color.fromBytes(SUN_COLOR[0], SUN_COLOR[1], SUN_COLOR[2], 255),
243
+ outlineColor: this.Cesium.Color.fromBytes(255, 140, 0, 255),
244
+ outlineWidth: 3,
245
+ disableDepthTestDistance: Number.POSITIVE_INFINITY,
246
+ },
247
+ });
248
+ this.sunBeam = this.dataSource.entities.add({
249
+ polyline: {
250
+ positions: [originEcef, sunEcef],
251
+ width: 2,
252
+ material: this.Cesium.Color.fromBytes(SUN_COLOR[0], SUN_COLOR[1], SUN_COLOR[2], 160),
253
+ depthFailMaterial: this.Cesium.Color.fromBytes(SUN_COLOR[0], SUN_COLOR[1], SUN_COLOR[2], 70),
254
+ arcType: this.Cesium.ArcType.NONE,
255
+ },
256
+ });
257
+ } else {
258
+ this.sunMarker.position = new this.Cesium.ConstantPositionProperty(sunEcef);
259
+ if (this.sunBeam?.polyline) {
260
+ this.sunBeam.polyline.positions = new this.Cesium.ConstantProperty([originEcef, sunEcef]);
261
+ }
262
+ }
263
+ if (this.sunMarker.point) {
264
+ this.sunMarker.point.show = new this.Cesium.ConstantProperty(show);
265
+ }
266
+ if (this.sunBeam?.polyline) {
267
+ this.sunBeam.polyline.show = new this.Cesium.ConstantProperty(show);
268
+ }
269
+ this.viewer.scene.requestRender();
270
+ }
271
+
272
+ /** Remove every dome entity and detach the data source from the viewer. */
273
+ destroy(): void {
274
+ this.dataSource.entities.removeAll();
275
+ void this.viewer.dataSources.remove(this.dataSource, true);
276
+ }
277
+ }
@@ -0,0 +1,70 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ import { describe, it } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { enuToViewerDirection, sunLightingForAltitude } from './solar-direction.js';
8
+
9
+ function assertVecClose(actual: number[], expected: number[], eps = 1e-9) {
10
+ for (let i = 0; i < 3; i++) {
11
+ assert.ok(
12
+ Math.abs(actual[i] - expected[i]) < eps,
13
+ `component ${i}: ${actual[i]} !≈ ${expected[i]}`,
14
+ );
15
+ }
16
+ }
17
+
18
+ describe('enuToViewerDirection', () => {
19
+ it('maps the cardinal frame with no rotation: east→+X, up→+Y, north→−Z', () => {
20
+ assertVecClose(enuToViewerDirection({ e: 1, n: 0, u: 0 }), [1, 0, 0]);
21
+ assertVecClose(enuToViewerDirection({ e: 0, n: 0, u: 1 }), [0, 1, 0]);
22
+ assertVecClose(enuToViewerDirection({ e: 0, n: 1, u: 0 }), [0, 0, -1]);
23
+ });
24
+
25
+ it('is the inverse of the cesium-bridge viewer→ENU matrix for a rotated site', () => {
26
+ // 30° Helmert rotation, deliberately unnormalized (scaled ×2) the way
27
+ // IFC files sometimes author the direction cosines.
28
+ const absc = 2 * Math.cos(Math.PI / 6);
29
+ const ordi = 2 * Math.sin(Math.PI / 6);
30
+ const a = Math.cos(Math.PI / 6);
31
+ const o = Math.sin(Math.PI / 6);
32
+
33
+ const enu = { e: 0.3, n: 0.8, u: 0.52 };
34
+ const [vx, vy, vz] = enuToViewerDirection(enu, absc, ordi);
35
+
36
+ // Forward map from cesium-bridge.ts (unit-scale): east = a·vx + o·vz,
37
+ // north = o·vx − a·vz, up = vy. Round-tripping must recover the input.
38
+ const norm = Math.hypot(enu.e, enu.n, enu.u);
39
+ assert.ok(Math.abs((a * vx + o * vz) - enu.e / norm) < 1e-9, 'east');
40
+ assert.ok(Math.abs((o * vx - a * vz) - enu.n / norm) < 1e-9, 'north');
41
+ assert.ok(Math.abs(vy - enu.u / norm) < 1e-9, 'up');
42
+ });
43
+
44
+ it('returns a unit vector', () => {
45
+ const v = enuToViewerDirection({ e: 3, n: 4, u: 5 }, 0.6, -0.8);
46
+ assert.ok(Math.abs(Math.hypot(...v) - 1) < 1e-9);
47
+ });
48
+ });
49
+
50
+ describe('sunLightingForAltitude', () => {
51
+ it('full sun at midday, none at night', () => {
52
+ assert.ok(sunLightingForAltitude(45).intensityFactor > 0.99);
53
+ assert.strictEqual(sunLightingForAltitude(-20).intensityFactor, 0);
54
+ });
55
+
56
+ it('warms toward the horizon', () => {
57
+ const noon = sunLightingForAltitude(60);
58
+ const sunset = sunLightingForAltitude(1);
59
+ assert.ok(sunset.color[2] < noon.color[2], 'blue drops near horizon');
60
+ assert.ok(sunset.color[1] < noon.color[1], 'green drops near horizon');
61
+ });
62
+
63
+ it('ambient fades through twilight to a night floor', () => {
64
+ assert.ok(sunLightingForAltitude(30).ambientFactor > 0.99);
65
+ const night = sunLightingForAltitude(-30).ambientFactor;
66
+ assert.ok(night > 0.1 && night < 0.25, `night floor, got ${night}`);
67
+ const dusk = sunLightingForAltitude(-5).ambientFactor;
68
+ assert.ok(dusk > night && dusk < 1, `twilight between floors, got ${dusk}`);
69
+ });
70
+ });
@@ -0,0 +1,94 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Map sun positions from the solar package's ENU frame into the WebGPU
7
+ * viewer's world space, and shape the sun's photometric properties from its
8
+ * altitude (warm low sun, twilight fade, night).
9
+ *
10
+ * Frame math — must stay the inverse of the viewer→ENU matrix built in
11
+ * `cesium-bridge.ts` (createCesiumBridge):
12
+ *
13
+ * east = absc·vx + ordi·vz
14
+ * north = ordi·vx − absc·vz
15
+ * up = vy
16
+ *
17
+ * With (absc, ordi) normalized that matrix is orthonormal, so the inverse is
18
+ * the transpose:
19
+ *
20
+ * vx = absc·e + ordi·n
21
+ * vy = u
22
+ * vz = ordi·e − absc·n
23
+ *
24
+ * Sanity check at no rotation (absc=1, ordi=0): east→+X, up→+Y, north→−Z.
25
+ */
26
+
27
+ import type { Enu } from '@ifc-lite/solar';
28
+
29
+ /**
30
+ * Convert an ENU unit direction to viewer/world space (Y-up) using the
31
+ * model's IfcMapConversion XAxisAbscissa/Ordinate rotation. Defaults match
32
+ * IFC's "no rotation" convention (cos=1, sin=0).
33
+ */
34
+ export function enuToViewerDirection(
35
+ enu: Enu,
36
+ xAxisAbscissa = 1,
37
+ xAxisOrdinate = 0,
38
+ ): [number, number, number] {
39
+ // The IFC pair may be unnormalized direction cosines — normalize first.
40
+ const len = Math.hypot(xAxisAbscissa, xAxisOrdinate) || 1;
41
+ const a = xAxisAbscissa / len;
42
+ const o = xAxisOrdinate / len;
43
+ const vx = a * enu.e + o * enu.n;
44
+ const vy = enu.u;
45
+ const vz = o * enu.e - a * enu.n;
46
+ const vlen = Math.hypot(vx, vy, vz) || 1;
47
+ return [vx / vlen, vy / vlen, vz / vlen];
48
+ }
49
+
50
+ export interface SunLighting {
51
+ /** Multiplier 0..1 on the preset's sun intensity. */
52
+ intensityFactor: number;
53
+ /** Sun light colour (warm at low altitudes, cool residual at night). */
54
+ color: [number, number, number];
55
+ /** Multiplier 0..~1 on the preset's hemisphere-ambient strength. */
56
+ ambientFactor: number;
57
+ }
58
+
59
+ function smooth(x: number, lo: number, hi: number): number {
60
+ const t = Math.min(1, Math.max(0, (x - lo) / (hi - lo)));
61
+ return t * t * (3 - 2 * t);
62
+ }
63
+
64
+ function mix(a: number, b: number, t: number): number {
65
+ return a + (b - a) * t;
66
+ }
67
+
68
+ /**
69
+ * Photometric shaping of the sun by altitude (degrees above horizon):
70
+ * full warm-white sun by ~15°, golden tint near the horizon, fading to
71
+ * zero through civil twilight (0…−6°), with ambient dimming to a small
72
+ * night floor so the model stays barely readable.
73
+ */
74
+ export function sunLightingForAltitude(altitudeDeg: number): SunLighting {
75
+ // Direct sun: none below the horizon, ramping in over the first ~10°.
76
+ const dayness = smooth(altitudeDeg, -1, 10);
77
+ // Twilight ambient: holds through −6° (civil twilight), then night floor.
78
+ const twilight = smooth(altitudeDeg, -10, 2);
79
+
80
+ // Warmth peaks at the horizon: white overhead → amber at 0°.
81
+ const warmth = 1 - smooth(altitudeDeg, 2, 25);
82
+ const color: [number, number, number] = [
83
+ 1.0,
84
+ mix(0.98, 0.72, warmth),
85
+ mix(0.95, 0.45, warmth),
86
+ ];
87
+
88
+ return {
89
+ intensityFactor: dayness,
90
+ color,
91
+ // Night keeps 18% ambient so geometry silhouettes stay visible.
92
+ ambientFactor: mix(0.18, 1.0, twilight),
93
+ };
94
+ }
@@ -0,0 +1,128 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Lighting presets for the WebGPU viewport.
7
+ *
8
+ * Each preset is a partial `LightingEnvironment`; unset fields fall back to
9
+ * the renderer defaults (the historical hardcoded look). The `default`
10
+ * preset is intentionally empty so it renders pixel-identical to every
11
+ * ifc-lite build before lighting became configurable.
12
+ *
13
+ * A preset IS the whole environment: every preset except `default` brings
14
+ * its sky along (`skyEnabled: true`) — picking "Day" gives day lighting AND
15
+ * a day sky, with no separate sky switch to discover.
16
+ *
17
+ * When the solar study drives the sun, the preset still supplies the base
18
+ * sun intensity and hemisphere colours; only direction/colour/fades are
19
+ * overridden per the computed sun altitude.
20
+ */
21
+
22
+ import type { LightingEnvironment } from '@ifc-lite/renderer';
23
+
24
+ export type LightingPresetId = 'default' | 'daylight' | 'overcast' | 'golden' | 'night';
25
+
26
+ export interface LightingPreset {
27
+ id: LightingPresetId;
28
+ label: string;
29
+ hint: string;
30
+ environment: LightingEnvironment;
31
+ }
32
+
33
+ export const LIGHTING_PRESETS: Record<LightingPresetId, LightingPreset> = {
34
+ default: {
35
+ id: 'default',
36
+ label: 'Default',
37
+ hint: 'The classic ifc-lite studio look',
38
+ environment: {},
39
+ },
40
+ daylight: {
41
+ id: 'daylight',
42
+ label: 'Day',
43
+ hint: 'Bright neutral daylight, high sun',
44
+ environment: {
45
+ skyEnabled: true,
46
+ sunDirection: [0.45, 0.83, 0.33],
47
+ sunColor: [1.0, 0.98, 0.92],
48
+ sunIntensity: 0.62,
49
+ skyColor: [0.42, 0.52, 0.65],
50
+ groundColor: [0.22, 0.19, 0.15],
51
+ ambientIntensity: 0.3,
52
+ exposure: 0.9,
53
+ },
54
+ },
55
+ overcast: {
56
+ id: 'overcast',
57
+ label: 'Overcast',
58
+ hint: 'Soft shadowless grey-sky light',
59
+ environment: {
60
+ skyEnabled: true,
61
+ sunDirection: [0.2, 0.95, 0.24],
62
+ sunColor: [0.9, 0.92, 0.95],
63
+ sunIntensity: 0.28,
64
+ skyColor: [0.55, 0.57, 0.6],
65
+ groundColor: [0.28, 0.28, 0.28],
66
+ ambientIntensity: 0.45,
67
+ fillIntensity: 0.1,
68
+ rimIntensity: 0.08,
69
+ exposure: 0.85,
70
+ sky: {
71
+ zenith: [0.5, 0.54, 0.58],
72
+ horizon: [0.66, 0.68, 0.7],
73
+ ground: [0.2, 0.2, 0.2],
74
+ },
75
+ },
76
+ },
77
+ golden: {
78
+ id: 'golden',
79
+ label: 'Evening',
80
+ hint: 'Low warm sun, golden-hour mood',
81
+ environment: {
82
+ skyEnabled: true,
83
+ sunDirection: [0.85, 0.18, 0.49],
84
+ sunColor: [1.0, 0.72, 0.45],
85
+ sunIntensity: 0.6,
86
+ skyColor: [0.3, 0.26, 0.32],
87
+ groundColor: [0.16, 0.11, 0.08],
88
+ ambientIntensity: 0.22,
89
+ exposure: 0.82,
90
+ },
91
+ },
92
+ night: {
93
+ id: 'night',
94
+ label: 'Night',
95
+ hint: 'Cool moonlit ambience',
96
+ environment: {
97
+ skyEnabled: true,
98
+ sunDirection: [-0.3, 0.7, -0.65],
99
+ sunColor: [0.65, 0.72, 0.9],
100
+ sunIntensity: 0.18,
101
+ skyColor: [0.1, 0.12, 0.2],
102
+ groundColor: [0.05, 0.05, 0.07],
103
+ ambientIntensity: 0.3,
104
+ fillIntensity: 0.08,
105
+ rimIntensity: 0.2,
106
+ exposure: 0.75,
107
+ // Explicit night sky — the altitude-derived palette would read the
108
+ // high moon direction as a midday sun and paint a blue daytime sky.
109
+ sky: {
110
+ zenith: [0.012, 0.018, 0.04],
111
+ horizon: [0.04, 0.05, 0.09],
112
+ ground: [0.02, 0.02, 0.025],
113
+ },
114
+ },
115
+ },
116
+ };
117
+
118
+ export const LIGHTING_PRESET_ORDER: LightingPresetId[] = [
119
+ 'default',
120
+ 'daylight',
121
+ 'overcast',
122
+ 'golden',
123
+ 'night',
124
+ ];
125
+
126
+ export function isLightingPresetId(value: string): value is LightingPresetId {
127
+ return value in LIGHTING_PRESETS;
128
+ }