@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.
- package/.turbo/turbo-build.log +32 -31
- package/CHANGELOG.md +58 -0
- package/dist/assets/{basketViewActivator-BSRgF1Hw.js → basketViewActivator-BHNb23Vw.js} +6 -6
- package/dist/assets/{bcf-3uE1MvcT.js → bcf-C0XZ2DSl.js} +1 -1
- package/dist/assets/{deflate-BLlUfw9-.js → deflate-DvvFcUtV.js} +1 -1
- package/dist/assets/{exporters-BL6UmxRa.js → exporters-CvORJOLn.js} +1345 -1434
- package/dist/assets/geometry.worker-B7X9DQQY.js +1 -0
- package/dist/assets/{geotiff-BydcIud8.js → geotiff-fSD_sVw_.js} +10 -10
- package/dist/assets/{ids-DFl74rTt.js → ids-BUOe5QQl.js} +951 -713
- package/dist/assets/idsValidation.worker-DEodXb0f.js +190468 -0
- package/dist/assets/ifc-lite_bg-CmMuB1zf.wasm +0 -0
- package/dist/assets/{index-BNTlm2lP.js → index-B6T42T86.js} +35235 -32937
- package/dist/assets/index-D0tqJL0X.css +1 -0
- package/dist/assets/{index.es-Bk4nLsyS.js → index.es-YGMensDM.js} +7 -7
- package/dist/assets/{jpeg-BvMO8-Tc.js → jpeg-0Sla88_N.js} +1 -1
- package/dist/assets/{jspdf.es.min-BZ_ed66E.js → jspdf.es.min-mnbLNj-p.js} +4 -4
- package/dist/assets/{lerc-CNnDpLpV.js → lerc-C7xUDHpL.js} +1 -1
- package/dist/assets/{lzw-DBaPrGGZ.js → lzw-CK480t0_.js} +1 -1
- package/dist/assets/{native-bridge-DFOoBvTg.js → native-bridge-sLWRanza.js} +1 -1
- package/dist/assets/{packbits-C7uyD2Bi.js → packbits-DcL4imYS.js} +1 -1
- package/dist/assets/parser.worker-BsGV6ml7.js +182 -0
- package/dist/assets/{pdf-DlqdjX9e.js → pdf-BARGfLmx.js} +8 -8
- package/dist/assets/raw-BMWh6mDy.js +1 -0
- package/dist/assets/{sandbox-0Z2NzeOJ.js → sandbox-BSiO04m8.js} +2801 -2609
- package/dist/assets/server-client-AlpWMVq9.js +741 -0
- package/dist/assets/{webimage-zN-oCabb.js → webimage-uy5DjZLk.js} +1 -1
- package/dist/assets/{xlsx-N2LbIR1G.js → xlsx-D02ho69_.js} +6 -6
- package/dist/assets/{zstd-Jk3QKIeb.js → zstd-DcR1TBwT.js} +1 -1
- package/dist/index.html +7 -7
- package/package.json +27 -32
- package/src/components/viewer/CesiumOverlay.tsx +195 -8
- package/src/components/viewer/IDSPanel.tsx +23 -7
- package/src/components/viewer/MainToolbar.tsx +31 -0
- package/src/components/viewer/SunSkyPanel.tsx +363 -0
- package/src/components/viewer/Viewport.tsx +49 -1
- package/src/components/viewer/ViewportContainer.tsx +20 -1
- package/src/components/viewer/useAnimationLoop.ts +19 -1
- package/src/disable-react-dev-perf-track.ts +38 -0
- package/src/hooks/has-entity-type.ts +33 -0
- package/src/hooks/ids/idsWorkerClient.ts +136 -0
- package/src/hooks/ingest/federationAlign.ts +1 -1
- package/src/hooks/ingest/pointCloudIngest.ts +22 -98
- package/src/hooks/ingest/viewerModelIngest.ts +8 -13
- package/src/hooks/useAlignmentLines3D.ts +5 -0
- package/src/hooks/useGridLines3D.ts +4 -0
- package/src/hooks/useIDS.ts +77 -13
- package/src/hooks/useIfcCache.ts +1 -1
- package/src/hooks/useIfcFederation.ts +1 -1
- package/src/hooks/useIfcServer.ts +1 -1
- package/src/hooks/useModelSelection.ts +1 -1
- package/src/hooks/useSolarEnvironment.ts +114 -0
- package/src/hooks/useSolarSweep.ts +66 -0
- package/src/hooks/useSymbolicAnnotations.ts +10 -0
- package/src/hooks/useViewerSelectors.ts +1 -1
- package/src/lib/geo/cesium-sun.ts +277 -0
- package/src/lib/geo/solar-direction.test.ts +70 -0
- package/src/lib/geo/solar-direction.ts +94 -0
- package/src/lib/lighting-presets.ts +128 -0
- package/src/lib/recent-files.ts +4 -24
- package/src/lib/solar-time.ts +55 -0
- package/src/main.tsx +5 -0
- package/src/store/index.ts +8 -0
- package/src/store/slices/annotationsSlice.test.ts +0 -16
- package/src/store/slices/cesiumSlice.ts +3 -3
- package/src/store/slices/dataSlice.test.ts +0 -40
- package/src/store/slices/environmentSlice.ts +101 -0
- package/src/store/slices/idsSlice.ts +6 -1
- package/src/store/slices/selectionSlice.test.ts +0 -43
- package/src/store/slices/solarSlice.ts +121 -0
- package/src/store/slices/visibilitySlice.test.ts +15 -45
- package/src/utils/loadingUtils.ts +1 -1
- package/src/workers/idsValidation.worker.ts +98 -0
- package/dist/assets/geometry.worker-DVwFYHTq.js +0 -1
- package/dist/assets/ifc-lite_bg-FPffpFK_.wasm +0 -0
- package/dist/assets/index-DpoJvkdg.css +0 -1
- package/dist/assets/parser.worker-U_PVhLNi.js +0 -182
- package/dist/assets/raw-p_2cfl6T.js +0 -1
- package/dist/assets/server-client-DUMy2mXg.js +0 -719
- package/src/components/ui/context-menu.tsx +0 -174
- 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 & 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 & 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
|
+
}
|