@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,363 @@
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
+ * Sun & Sky panel — one place for sky, lighting and the sun-path study,
7
+ * aware of which rendering path is active:
8
+ *
9
+ * • Standalone (WebGPU): lighting preset + exposure shape the model's
10
+ * shading, Sky draws the procedural sky, and the sun study (when the
11
+ * model is georeferenced) drives the real sun direction.
12
+ * • World context (Cesium): the model is composited into Cesium, which
13
+ * lights the scene from its sun and atmosphere — so preset/exposure
14
+ * hide and Sky toggles the atmosphere instead. The study adds the
15
+ * sun-path dome and real cast shadows.
16
+ *
17
+ * The whole panel collapses to its header row; the study keeps running
18
+ * (sweep animation lives in useSolarSweep at the viewport level).
19
+ */
20
+
21
+ import { useState } from 'react';
22
+ import { Play, Pause, ChevronDown, ChevronUp } from 'lucide-react';
23
+ import { useViewerStore } from '@/store';
24
+ import { cn } from '@/lib/utils';
25
+ import type { CesiumDataSource } from '@/store/slices/cesiumSlice';
26
+ import type { SolarSweepMode } from '@/store/slices/solarSlice';
27
+ import { LIGHTING_PRESETS, LIGHTING_PRESET_ORDER, isLightingPresetId } from '@/lib/lighting-presets';
28
+ import {
29
+ solarDisplayOffsetMinutes,
30
+ toSolarDateInputValue,
31
+ solarMinutesOfDay,
32
+ composeSolarMs,
33
+ formatSolarTime,
34
+ } from '@/lib/solar-time';
35
+
36
+ const CONTEXT_SOURCES: Array<{ value: CesiumDataSource; label: string; hint: string }> = [
37
+ { value: 'osm-buildings', label: 'OSM Buildings', hint: 'Extruded footprints over the satellite base map' },
38
+ { value: 'google-photorealistic', label: 'Photorealistic', hint: 'Google 3D Tiles — textured real-world context' },
39
+ ];
40
+
41
+ const SWEEP_MODES: Array<{ value: SolarSweepMode; label: string; hint: string }> = [
42
+ { value: 'day', label: 'Day', hint: 'Sweep the time of day' },
43
+ { value: 'year', label: 'Year', hint: 'Sweep the date across the year' },
44
+ ];
45
+
46
+ export function SunSkyPanel() {
47
+ const open = useViewerStore((s) => s.envPanelOpen);
48
+
49
+ const skyEnabled = useViewerStore((s) => s.envSkyEnabled);
50
+ const setSkyEnabled = useViewerStore((s) => s.setEnvSkyEnabled);
51
+ const preset = useViewerStore((s) => s.envPreset);
52
+ const setPreset = useViewerStore((s) => s.setEnvPreset);
53
+ const exposure = useViewerStore((s) => s.envExposure);
54
+ const setExposure = useViewerStore((s) => s.setEnvExposure);
55
+
56
+ const cesiumAvailable = useViewerStore((s) => s.cesiumAvailable);
57
+ const cesiumEnabled = useViewerStore((s) => s.cesiumEnabled);
58
+ const setCesiumEnabled = useViewerStore((s) => s.setCesiumEnabled);
59
+ const dataSource = useViewerStore((s) => s.cesiumDataSource);
60
+ const setDataSource = useViewerStore((s) => s.setCesiumDataSource);
61
+
62
+ const solarEnabled = useViewerStore((s) => s.solarEnabled);
63
+ const setSolarEnabled = useViewerStore((s) => s.setSolarEnabled);
64
+ const dateMs = useViewerStore((s) => s.solarDateMs);
65
+ const setDateMs = useViewerStore((s) => s.setSolarDateMs);
66
+ const showSunPath = useViewerStore((s) => s.solarShowSunPath);
67
+ const setShowSunPath = useViewerStore((s) => s.setSolarShowSunPath);
68
+ const showShadows = useViewerStore((s) => s.solarShowShadows);
69
+ const setShowShadows = useViewerStore((s) => s.setSolarShowShadows);
70
+ const sunInfo = useViewerStore((s) => s.solarSunInfo);
71
+ const useLocalTime = useViewerStore((s) => s.solarUseLocalTime);
72
+ const setUseLocalTime = useViewerStore((s) => s.setSolarUseLocalTime);
73
+ const playing = useViewerStore((s) => s.solarPlaying);
74
+ const togglePlaying = useViewerStore((s) => s.toggleSolarPlaying);
75
+ const sweepMode = useViewerStore((s) => s.solarSweepMode);
76
+ const setSweepMode = useViewerStore((s) => s.setSolarSweepMode);
77
+
78
+ const [collapsed, setCollapsed] = useState(false);
79
+
80
+ if (!open) return null;
81
+
82
+ const offsetMin = solarDisplayOffsetMinutes(useLocalTime, sunInfo?.longitude);
83
+ const minutes = solarMinutesOfDay(dateMs, offsetMin);
84
+ const tzLabel = useLocalTime
85
+ ? `Site${sunInfo ? ` (UTC${offsetMin >= 0 ? '+' : '−'}${Math.abs(offsetMin / 60).toFixed(1)})` : ''}`
86
+ : 'UTC';
87
+
88
+ return (
89
+ <div className="pointer-events-auto w-60 bg-background/90 backdrop-blur-sm rounded-lg border shadow-lg p-2 flex flex-col gap-2 text-xs">
90
+ {/* Header — click anywhere to collapse/expand */}
91
+ <button
92
+ type="button"
93
+ onClick={() => setCollapsed(!collapsed)}
94
+ aria-expanded={!collapsed}
95
+ className="flex items-center justify-between gap-2 text-left"
96
+ >
97
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
98
+ Sun &amp; Sky
99
+ </span>
100
+ <span className="text-muted-foreground">
101
+ {collapsed ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />}
102
+ </span>
103
+ </button>
104
+
105
+ {!collapsed && (
106
+ <>
107
+ {/* Environment — the preset IS the whole look: every preset except
108
+ Default brings its own sky. In the world context the model is
109
+ lit by Cesium's sun instead, so the choice becomes a single
110
+ Atmosphere switch. */}
111
+ {cesiumEnabled ? (
112
+ <div className="flex items-center gap-1">
113
+ <ToggleChip
114
+ label="Atmosphere"
115
+ active={skyEnabled}
116
+ onClick={() => setSkyEnabled(!skyEnabled)}
117
+ title="Sky, sun disc and haze in the world context"
118
+ />
119
+ <span className="flex-1 px-1 text-[9px] leading-tight text-muted-foreground">
120
+ Lighting follows the sun &amp; atmosphere
121
+ </span>
122
+ </div>
123
+ ) : (
124
+ <label className="flex flex-col gap-0.5">
125
+ <span className="text-[9px] uppercase tracking-wider text-muted-foreground">Environment</span>
126
+ <select
127
+ aria-label="Environment preset"
128
+ value={preset}
129
+ onChange={(e) => { if (isLightingPresetId(e.target.value)) setPreset(e.target.value); }}
130
+ title={LIGHTING_PRESETS[preset].hint}
131
+ className="w-full bg-muted/40 rounded px-1.5 py-1 border text-foreground text-[10px]"
132
+ >
133
+ {LIGHTING_PRESET_ORDER.map((id) => (
134
+ <option key={id} value={id}>
135
+ {LIGHTING_PRESETS[id].label}{id === 'default' ? ' (no sky)' : ''}
136
+ </option>
137
+ ))}
138
+ </select>
139
+ </label>
140
+ )}
141
+
142
+ {/* Exposure — WebGPU shading only, hidden in world-context mode */}
143
+ {!cesiumEnabled && (
144
+ <label className="flex flex-col gap-0.5">
145
+ <span className="flex justify-between text-[9px] uppercase tracking-wider text-muted-foreground">
146
+ <span>Exposure</span>
147
+ <button
148
+ type="button"
149
+ onClick={() => setExposure(1)}
150
+ title="Reset exposure"
151
+ className={cn('tabular-nums transition-colors', exposure !== 1 && 'text-foreground hover:text-teal-600')}
152
+ >
153
+ {exposure.toFixed(2)}×
154
+ </button>
155
+ </span>
156
+ <input
157
+ type="range"
158
+ min={0.4}
159
+ max={2}
160
+ step={0.05}
161
+ value={exposure}
162
+ onChange={(e) => setExposure(Number(e.target.value))}
163
+ className="w-full accent-teal-600"
164
+ />
165
+ </label>
166
+ )}
167
+
168
+ {/* Sun study — needs a georeferenced model for the real sun */}
169
+ {cesiumAvailable && (
170
+ <>
171
+ <div className="flex items-center justify-between gap-2 pt-2 border-t">
172
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
173
+ Sun study
174
+ </span>
175
+ <button
176
+ type="button"
177
+ aria-pressed={solarEnabled}
178
+ onClick={() => setSolarEnabled(!solarEnabled)}
179
+ className={cn(
180
+ 'px-2 py-0.5 rounded text-[10px] font-semibold uppercase transition-colors',
181
+ solarEnabled ? 'bg-amber-500 text-zinc-950' : 'text-muted-foreground hover:bg-muted hover:text-foreground',
182
+ )}
183
+ >
184
+ {solarEnabled ? 'On' : 'Off'}
185
+ </button>
186
+ </div>
187
+
188
+ {solarEnabled && (
189
+ <>
190
+ {/* Date + play/pause */}
191
+ <div className="flex items-end gap-1.5">
192
+ {/* Not a <label>: the tz toggle is interactive, so wrapping the
193
+ input in a label would forward tz clicks to the date picker. */}
194
+ <div className="flex flex-col gap-0.5 flex-1">
195
+ <span className="flex justify-between text-[9px] uppercase tracking-wider text-muted-foreground">
196
+ <span>Date</span>
197
+ <button
198
+ type="button"
199
+ onClick={() => setUseLocalTime(!useLocalTime)}
200
+ title="Toggle UTC / local solar time (from site longitude)"
201
+ className="hover:text-foreground transition-colors"
202
+ >
203
+ {tzLabel}
204
+ </button>
205
+ </span>
206
+ <input
207
+ type="date"
208
+ aria-label="Sun study date"
209
+ value={toSolarDateInputValue(dateMs, offsetMin)}
210
+ onChange={(e) => setDateMs(composeSolarMs(e.target.value, minutes, offsetMin))}
211
+ className="w-full bg-muted/40 rounded px-1.5 py-1 border text-foreground"
212
+ />
213
+ </div>
214
+ <button
215
+ type="button"
216
+ onClick={togglePlaying}
217
+ aria-label={playing ? 'Pause sweep' : 'Play sweep'}
218
+ aria-pressed={playing}
219
+ className={cn(
220
+ 'h-[26px] w-[26px] flex items-center justify-center rounded transition-colors shrink-0',
221
+ playing ? 'bg-teal-600 text-white' : 'text-muted-foreground hover:bg-muted hover:text-foreground',
222
+ )}
223
+ >
224
+ {playing ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
225
+ </button>
226
+ </div>
227
+
228
+ {/* Time of day */}
229
+ <label className="flex flex-col gap-0.5">
230
+ <span className="flex justify-between text-[9px] uppercase tracking-wider text-muted-foreground">
231
+ <span>Time</span>
232
+ <span className="tabular-nums text-foreground">{formatSolarTime(dateMs, offsetMin)}</span>
233
+ </span>
234
+ <input
235
+ type="range"
236
+ min={0}
237
+ max={1439}
238
+ step={5}
239
+ value={minutes}
240
+ onChange={(e) => setDateMs(composeSolarMs(toSolarDateInputValue(dateMs, offsetMin), Number(e.target.value), offsetMin))}
241
+ className="w-full accent-teal-600"
242
+ />
243
+ </label>
244
+
245
+ {/* Sweep mode */}
246
+ <div className="flex gap-1">
247
+ {SWEEP_MODES.map((m) => (
248
+ <button
249
+ key={m.value}
250
+ type="button"
251
+ title={m.hint}
252
+ aria-pressed={sweepMode === m.value}
253
+ onClick={() => setSweepMode(m.value)}
254
+ className={cn(
255
+ 'flex-1 px-1.5 py-1 rounded text-[10px] transition-colors',
256
+ sweepMode === m.value
257
+ ? 'bg-teal-600 text-white'
258
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
259
+ )}
260
+ >
261
+ {m.label}
262
+ </button>
263
+ ))}
264
+ </div>
265
+
266
+ {/* World-context extras: dome + shadows + context source */}
267
+ {cesiumEnabled ? (
268
+ <>
269
+ <div className="flex gap-1">
270
+ <ToggleChip className="flex-1" label="Dome" active={showSunPath} onClick={() => setShowSunPath(!showSunPath)} />
271
+ <ToggleChip className="flex-1" label="Shadows" active={showShadows} onClick={() => setShowShadows(!showShadows)} />
272
+ </div>
273
+ <div className="flex gap-1">
274
+ {CONTEXT_SOURCES.map((src) => (
275
+ <button
276
+ key={src.value}
277
+ type="button"
278
+ title={src.hint}
279
+ aria-pressed={dataSource === src.value}
280
+ onClick={() => setDataSource(src.value)}
281
+ className={cn(
282
+ 'flex-1 px-1.5 py-1 rounded text-[10px] transition-colors',
283
+ dataSource === src.value
284
+ ? 'bg-teal-600 text-white'
285
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
286
+ )}
287
+ >
288
+ {src.label}
289
+ </button>
290
+ ))}
291
+ </div>
292
+ </>
293
+ ) : (
294
+ <button
295
+ type="button"
296
+ onClick={() => setCesiumEnabled(true)}
297
+ className="text-left text-[9px] leading-snug text-muted-foreground hover:text-foreground transition-colors"
298
+ >
299
+ The sun lights the model directly; for the sun-path dome and
300
+ real cast shadows, click to enable the 3D world context.
301
+ </button>
302
+ )}
303
+
304
+ {!sunInfo && (
305
+ <p className="text-[9px] leading-snug text-amber-600 dark:text-amber-500">
306
+ Site location unavailable — the model's projected CRS
307
+ could not be resolved, so the real sun position can't be
308
+ computed.
309
+ </p>
310
+ )}
311
+
312
+ {/* Readout */}
313
+ <div className="mt-1 pt-2 border-t grid grid-cols-2 gap-x-2 gap-y-0.5 tabular-nums">
314
+ <Readout label="Azimuth" value={sunInfo ? `${sunInfo.azimuth.toFixed(1)}°` : '—'} />
315
+ <Readout label="Altitude" value={sunInfo ? `${sunInfo.altitude.toFixed(1)}°` : '—'} />
316
+ <Readout label="Sunrise" value={formatSolarTime(sunInfo?.sunriseMs ?? null, offsetMin)} />
317
+ <Readout label="Sunset" value={formatSolarTime(sunInfo?.sunsetMs ?? null, offsetMin)} />
318
+ <Readout label="Noon" value={formatSolarTime(sunInfo?.solarNoonMs ?? null, offsetMin)} />
319
+ <Readout
320
+ label="Site"
321
+ value={sunInfo ? `${sunInfo.latitude.toFixed(2)}, ${sunInfo.longitude.toFixed(2)}` : '—'}
322
+ />
323
+ </div>
324
+ </>
325
+ )}
326
+ </>
327
+ )}
328
+ </>
329
+ )}
330
+ </div>
331
+ );
332
+ }
333
+
334
+ /** Small pill toggle button. */
335
+ function ToggleChip({ label, active, onClick, title, className }: {
336
+ label: string; active: boolean; onClick: () => void; title?: string; className?: string;
337
+ }) {
338
+ return (
339
+ <button
340
+ type="button"
341
+ onClick={onClick}
342
+ aria-pressed={active}
343
+ title={title}
344
+ className={cn(
345
+ 'px-2 py-1 rounded text-[10px] font-semibold uppercase transition-colors',
346
+ active ? 'bg-teal-600 text-white' : 'text-muted-foreground hover:bg-muted hover:text-foreground',
347
+ className,
348
+ )}
349
+ >
350
+ {label}
351
+ </button>
352
+ );
353
+ }
354
+
355
+ /** One label/value cell in the sun readout grid. */
356
+ function Readout({ label, value }: { label: string; value: string }) {
357
+ return (
358
+ <>
359
+ <span className="text-muted-foreground">{label}</span>
360
+ <span className="text-right text-foreground">{value}</span>
361
+ </>
362
+ );
363
+ }
@@ -7,9 +7,11 @@
7
7
  */
8
8
 
9
9
  import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
10
- import { Renderer, type VisualEnhancementOptions } from '@ifc-lite/renderer';
10
+ import { Renderer, type VisualEnhancementOptions, type LightingEnvironment } from '@ifc-lite/renderer';
11
11
  import type { MeshData, CoordinateInfo, PointCloudAsset } from '@ifc-lite/geometry';
12
12
  import { useViewerStore, resolveEntityRef, type MeasurePoint, type SnapVisualization } from '@/store';
13
+ import { LIGHTING_PRESETS } from '@/lib/lighting-presets';
14
+ import { sunLightingForAltitude } from '@/lib/geo/solar-direction';
13
15
  import {
14
16
  useSelectionState,
15
17
  useVisibilityState,
@@ -348,6 +350,51 @@ export function Viewport({
348
350
  rendererRef.current?.requestRender();
349
351
  }, [cesiumActive, theme]);
350
352
 
353
+ // ── Lighting environment ───────────────────────────────────────────────
354
+ // Compose the renderer's lighting from the active preset (which brings
355
+ // its own sky — picking "Day" means day lighting AND a day sky), the
356
+ // user's exposure trim, and (when the solar study runs) the true sun
357
+ // position at the site. The sky pass must stay OFF while Cesium is
358
+ // active — the WebGPU canvas composites over Cesium with a transparent
359
+ // clear, and Cesium draws its own atmosphere.
360
+ const envPreset = useViewerStore((s) => s.envPreset);
361
+ const envExposure = useViewerStore((s) => s.envExposure);
362
+ const solarEnabledForEnv = useViewerStore((s) => s.solarEnabled);
363
+ const solarSunDirection = useViewerStore((s) => s.solarSunDirection);
364
+ const solarSunAltitude = useViewerStore((s) => s.solarSunInfo?.altitude);
365
+
366
+ const environment = useMemo<LightingEnvironment>(() => {
367
+ const preset = LIGHTING_PRESETS[envPreset].environment;
368
+ const env: LightingEnvironment = {
369
+ ...preset,
370
+ skyEnabled: (preset.skyEnabled ?? false) && !cesiumActive,
371
+ exposure: (preset.exposure ?? 0.85) * envExposure,
372
+ };
373
+ if (solarEnabledForEnv && solarSunDirection) {
374
+ const altitude = solarSunAltitude
375
+ ?? Math.asin(Math.max(-1, Math.min(1, solarSunDirection[1]))) * (180 / Math.PI);
376
+ const sun = sunLightingForAltitude(altitude);
377
+ env.sunDirection = solarSunDirection;
378
+ env.sunColor = sun.color;
379
+ env.sunIntensity = (preset.sunIntensity ?? 0.55) * sun.intensityFactor;
380
+ env.ambientIntensity = (preset.ambientIntensity ?? 0.25) * sun.ambientFactor;
381
+ // Let the sky derive its palette from the real sun altitude.
382
+ delete env.sky;
383
+ }
384
+ return env;
385
+ }, [
386
+ envPreset,
387
+ envExposure,
388
+ cesiumActive,
389
+ solarEnabledForEnv,
390
+ solarSunDirection,
391
+ solarSunAltitude,
392
+ ]);
393
+ const environmentRef = useLatestRef(environment);
394
+ useEffect(() => {
395
+ rendererRef.current?.requestRender();
396
+ }, [environment]);
397
+
351
398
  // Animation frame ref
352
399
  const animationFrameRef = useRef<number | null>(null);
353
400
  const lastFrameTimeRef = useRef<number>(0);
@@ -1095,6 +1142,7 @@ export function Viewport({
1095
1142
  sectionRangeRef,
1096
1143
  modelBoundsRef,
1097
1144
  visualEnhancementRef,
1145
+ environmentRef,
1098
1146
  selectedEntityIdsRef,
1099
1147
  coordinateInfoRef,
1100
1148
  isInteractingRef,
@@ -14,6 +14,9 @@ import { BasketPresentationDock } from './BasketPresentationDock';
14
14
  import { BCFOverlay } from './bcf/BCFOverlay';
15
15
  import { CesiumOverlay } from './CesiumOverlay';
16
16
  import { CesiumPlacementEditor } from './CesiumPlacementEditor';
17
+ import { SunSkyPanel } from './SunSkyPanel';
18
+ import { useSolarEnvironment } from '@/hooks/useSolarEnvironment';
19
+ import { useSolarSweep } from '@/hooks/useSolarSweep';
17
20
  import { getViewerStoreApi, useViewerStore } from '@/store';
18
21
  import { toGlobalIdFromModels } from '@/store/globalId';
19
22
  import { collectIfcBuildingStoreyElementsWithIfcSpace } from '@/store/basketVisibleSet';
@@ -73,6 +76,7 @@ export function ViewportContainer() {
73
76
  const resetViewerState = useViewerStore((s) => s.resetViewerState);
74
77
  const bcfOverlayVisible = useViewerStore((s) => s.bcfOverlayVisible);
75
78
  const cesiumEnabled = useViewerStore((s) => s.cesiumEnabled);
79
+ const solarEnabled = useViewerStore((s) => s.solarEnabled);
76
80
  const cesiumPlacementDraft = useViewerStore((s) => s.cesiumPlacementDraft);
77
81
  const cesiumPlacementDraftModelId = useViewerStore((s) => s.cesiumPlacementDraftModelId);
78
82
  const anchorModelIdOverride = useViewerStore((s) => s.anchorModelIdOverride);
@@ -266,8 +270,10 @@ export function ViewportContainer() {
266
270
 
267
271
  // Extract georeferencing info merged with any live mutations (for Cesium overlay).
268
272
  // Reacts to: model load, Cesium toggle, and every georef field edit.
273
+ // Also computed while the solar study runs without Cesium — the WebGPU sun
274
+ // needs the site's lat/lon + map rotation to track the studied instant.
269
275
  const georef = useMemo(() => {
270
- if (!cesiumEnabled) return null;
276
+ if (!cesiumEnabled && !solarEnabled) return null;
271
277
 
272
278
  const applyPlacementDraft = <T extends { mapConversion?: MapConversion }>(
273
279
  modelId: string,
@@ -347,6 +353,7 @@ export function ViewportContainer() {
347
353
  return null;
348
354
  }, [
349
355
  cesiumEnabled,
356
+ solarEnabled,
350
357
  storeModels,
351
358
  ifcDataStore,
352
359
  georefMutations,
@@ -357,6 +364,12 @@ export function ViewportContainer() {
357
364
  anchorModelIdOverride,
358
365
  ]);
359
366
 
367
+ // Feed the solar study's sun position into the WebGPU lighting environment
368
+ // (viewer-space sun direction + panel readout when Cesium is off).
369
+ useSolarEnvironment(georef);
370
+ // Sweep animation runs here so collapsing/closing the panel doesn't stop it.
371
+ useSolarSweep();
372
+
360
373
  // Determine whether Cesium button should be visible (model has georef or user added it via mutations).
361
374
  // Runs independently of cesiumEnabled so the button appears/disappears reactively.
362
375
  useEffect(() => {
@@ -1104,6 +1117,12 @@ export function ViewportContainer() {
1104
1117
  storeyElevations={georef.storeyElevations}
1105
1118
  />
1106
1119
  )}
1120
+ {/* Sun & Sky panel — sky, lighting presets and the sun-path study.
1121
+ Anchored below the ViewCube (60px cube at top-6 right-6, plus the
1122
+ overflow of its rotating faces) so it never covers navigation. */}
1123
+ <div className="absolute top-32 right-4 z-10 pointer-events-none flex flex-col items-end gap-2">
1124
+ <SunSkyPanel />
1125
+ </div>
1107
1126
  {cesiumEnabled && georef?.mapConversion && georef.baseMapConversion && (
1108
1127
  <CesiumPlacementEditor
1109
1128
  modelId={georef.sourceModelId}
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { useEffect, type MutableRefObject, type RefObject } from 'react';
20
- import type { Renderer, VisualEnhancementOptions } from '@ifc-lite/renderer';
20
+ import type { Renderer, VisualEnhancementOptions, LightingEnvironment } from '@ifc-lite/renderer';
21
21
  import type { CoordinateInfo } from '@ifc-lite/geometry';
22
22
  import type { SectionPlane } from '@/store';
23
23
 
@@ -37,6 +37,8 @@ export interface UseAnimationLoopParams {
37
37
  selectedModelIndexRef: MutableRefObject<number | undefined>;
38
38
  clearColorRef: MutableRefObject<[number, number, number, number]>;
39
39
  visualEnhancementRef: MutableRefObject<VisualEnhancementOptions>;
40
+ /** Lighting environment (sun, hemisphere ambient, exposure, sky pass). */
41
+ environmentRef: MutableRefObject<LightingEnvironment>;
40
42
  sectionPlaneRef: MutableRefObject<SectionPlane>;
41
43
  sectionRangeRef: MutableRefObject<{ min: number; max: number } | null>;
42
44
  /**
@@ -77,6 +79,7 @@ export function useAnimationLoop(params: UseAnimationLoopParams): void {
77
79
  selectedModelIndexRef,
78
80
  clearColorRef,
79
81
  visualEnhancementRef,
82
+ environmentRef,
80
83
  sectionPlaneRef,
81
84
  sectionRangeRef,
82
85
  modelBoundsRef,
@@ -102,6 +105,7 @@ export function useAnimationLoop(params: UseAnimationLoopParams): void {
102
105
  let lastRotationUpdate = 0;
103
106
  let lastScaleUpdate = 0;
104
107
  let lastRenderTime = 0;
108
+ let wasAnimating = false;
105
109
 
106
110
  // Adaptive render throttle: large models get fewer FPS during continuous
107
111
  // rendering (interaction + inertia) to prevent the main thread from being
@@ -150,6 +154,16 @@ export function useAnimationLoop(params: UseAnimationLoopParams): void {
150
154
  // 2. Camera update (animation / inertia)
151
155
  const isAnimating = camera.update(deltaTime);
152
156
 
157
+ // Camera tweens (Home / view cube / zoom-extent) render their frames
158
+ // with isInteracting=true; without a settle render the last tween frame
159
+ // could stay on screen at degraded quality until the next incidental
160
+ // render. Mouse/wheel/touch paths already request their own settle
161
+ // frame on release — this covers the animation path.
162
+ if (wasAnimating && !isAnimating && !isInteractingRef.current) {
163
+ renderer.requestRender();
164
+ }
165
+ wasAnimating = isAnimating;
166
+
153
167
  // 3. Render if anything changed
154
168
  // Peek first — only consume the flag when we actually commit to rendering.
155
169
  // This prevents a throttled frame from eating the dirty flag.
@@ -174,7 +188,11 @@ export function useAnimationLoop(params: UseAnimationLoopParams): void {
174
188
  selectedModelIndex: selectedModelIndexRef.current,
175
189
  clearColor: clearColorRef.current,
176
190
  visualEnhancement: visualEnhancementRef.current,
191
+ environment: environmentRef.current,
177
192
  isInteracting: isInteractingRef.current || isAnimating,
193
+ // Let the effects governor judge missed frames against the
194
+ // intentional large-model throttle instead of display refresh.
195
+ interactionFrameIntervalMs: continuousThrottleMs || undefined,
178
196
  buildingRotation: coordinateInfoRef.current?.buildingRotation,
179
197
  sectionPlane: activeToolRef.current === 'section' ? {
180
198
  axis: sectionPlaneRef.current.axis,
@@ -0,0 +1,38 @@
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
+ * Disable React 19.2's DEV-mode component-render Performance tracking.
7
+ *
8
+ * react-dom (dev build) calls `logComponentRender` on every commit whose props
9
+ * changed; it recursively walks the prop diff (`addObjectDiffToProperties` →
10
+ * `addObjectToProperties`, a `for..in` recursion with no depth/size bound) to feed
11
+ * the browser's Performance timeline. The viewer passes large IFC `geometryResult`
12
+ * (typed arrays — `for..in` enumerates every index across thousands of meshes) and
13
+ * the `ifcDataStore` (circular relationship-graph refs → unbounded recursion) as
14
+ * props, so on big models (schependomlaan, Holter Tower) the diff array grows until
15
+ * `RangeError: Invalid array length` and a multi-GB main-thread OOM — the load
16
+ * "stops halfway" and the tab stalls/crashes. (Playwright-confirmed: with this
17
+ * tracker off, Holter loads in ~350MB instead of OOMing at ~4GB.)
18
+ *
19
+ * React gates the entire tracker on `supportsUserTiming`, cached at react-dom init
20
+ * from `typeof performance.measure === 'function'`. Making `performance.measure`
21
+ * unavailable BEFORE react-dom initializes (this is the first import in main.tsx)
22
+ * disables ONLY this tracking; the viewer times with `performance.now()`, not
23
+ * `measure`. DEV-only — the production build strips the tracker, so this is a no-op.
24
+ */
25
+ if (import.meta.env.DEV && typeof performance !== 'undefined') {
26
+ try {
27
+ (performance as unknown as { measure?: unknown }).measure = undefined;
28
+ } catch (err) {
29
+ // `performance.measure` is read-only in some engines — surface it so a
30
+ // failed patch (which leaves large models OOM-prone) isn't silently hidden.
31
+ if (typeof console !== 'undefined') {
32
+ console.warn(
33
+ '[perf-track] could not disable React DEV perf tracking; large models may OOM',
34
+ err,
35
+ );
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,33 @@
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 type { IfcDataStore } from '@ifc-lite/parser';
6
+
7
+ /**
8
+ * Cheap presence check against the data store's type index — true if the store
9
+ * contains at least one entity of any of the given IFC types.
10
+ *
11
+ * `entityIndex.byType` indexes EVERY parsed entity (it backs on-demand
12
+ * extraction), so absence here is authoritative: a model with no `IfcAlignment`
13
+ * truly has none. Keys are the raw IFC type strings, which are UPPERCASE in
14
+ * STEP files but can be mixed-case on some cache-load paths, so we check both.
15
+ *
16
+ * Used to skip the always-on full-source WASM scans (`parseAlignmentLines`,
17
+ * `parseGridLines`, `parseSymbolicRepresentations`) on models that have none of
18
+ * the relevant entities — those scans copy the entire IFC source into the WASM
19
+ * heap (hundreds of ms on a 170MB file) on the main thread during load, purely
20
+ * to find nothing.
21
+ *
22
+ * Returns `true` (i.e. do NOT skip) when the index is missing/empty, so gating
23
+ * can never drop real data — worst case is the pre-existing behaviour.
24
+ */
25
+ export function hasEntityType(store: IfcDataStore, ...types: string[]): boolean {
26
+ const byType = store.entityIndex?.byType;
27
+ if (!byType || byType.size === 0) return true;
28
+ for (const t of types) {
29
+ if ((byType.get(t.toUpperCase())?.length ?? 0) > 0) return true;
30
+ if ((byType.get(t)?.length ?? 0) > 0) return true;
31
+ }
32
+ return false;
33
+ }