@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
@@ -24,19 +24,11 @@ export interface RecentFileEntry {
24
24
  name: string;
25
25
  size: number;
26
26
  timestamp: number;
27
- /** Native filesystem path (Tauri only) — enables direct re-open from disk. */
28
- path?: string;
29
- /** Last-modified epoch in ms when known (Tauri stat). */
30
- modifiedMs?: number | null;
31
27
  }
32
28
 
33
- // Input shape for `recordRecentFiles` — accepts the optional native fields
34
- // so callers can persist a path / modifiedMs without lying about the type.
35
29
  export type RecentFileInput = {
36
30
  name: string;
37
31
  size: number;
38
- path?: string;
39
- modifiedMs?: number | null;
40
32
  };
41
33
 
42
34
  // ── localStorage (metadata) ─────────────────────────────────────────────
@@ -46,12 +38,10 @@ export function getRecentFiles(): RecentFileEntry[] {
46
38
  catch { return []; }
47
39
  }
48
40
 
49
- // Path-aware dedup key: when a native filesystem path is available it
50
- // uniquely identifies the file (so `A/model.ifc` and `B/model.ifc` are
51
- // kept separate); otherwise fall back to the name (browser uploads
52
- // don't expose paths).
53
- function recentKey(f: { name: string; path?: string }): string {
54
- return f.path ? `path:${f.path}` : `name:${f.name}`;
41
+ // Browser uploads don't expose filesystem paths, so the file name is the
42
+ // dedup key.
43
+ function recentKey(f: { name: string }): string {
44
+ return `name:${f.name}`;
55
45
  }
56
46
 
57
47
  export function recordRecentFiles(files: RecentFileInput[]) {
@@ -62,8 +52,6 @@ export function recordRecentFiles(files: RecentFileInput[]) {
62
52
  name: f.name,
63
53
  size: f.size,
64
54
  timestamp: Date.now(),
65
- path: f.path,
66
- modifiedMs: f.modifiedMs ?? null,
67
55
  }));
68
56
  localStorage.setItem(KEY, JSON.stringify([...entries, ...existing].slice(0, 10)));
69
57
  } catch (err) {
@@ -130,14 +118,6 @@ export async function cacheFileBlobs(files: File[]): Promise<void> {
130
118
 
131
119
  /** Retrieve a cached file blob and reconstruct a File object. */
132
120
  export async function getCachedFile(target: string | RecentFileEntry): Promise<File | null> {
133
- // Path-bearing entries (Tauri filesystem) are uniquely keyed by path
134
- // in the recents list, but the IndexedDB cache is name-keyed. A
135
- // name-only hit could resolve `A/model.ifc` to the cached blob from
136
- // `B/model.ifc`, opening the wrong file silently. Defer to the
137
- // caller's native re-open path instead.
138
- if (typeof target !== 'string' && target.path) {
139
- return null;
140
- }
141
121
  const name = typeof target === 'string' ? target : target.name;
142
122
  try {
143
123
  const db = await openDB();
@@ -0,0 +1,55 @@
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
+ * Display-frame time helpers for the solar study.
7
+ *
8
+ * The studied instant is always stored as an absolute UTC epoch. For display
9
+ * the UI defaults to *local solar time* derived from the site longitude
10
+ * (15°/hour) — civil-timezone-agnostic on purpose, since sun-path studies
11
+ * care about solar time and a longitude offset needs no timezone database or
12
+ * DST rules. The user can switch the readout to UTC.
13
+ */
14
+
15
+ export const MS_PER_MIN = 60_000;
16
+ export const MS_PER_DAY = 86_400_000;
17
+
18
+ /** Longitude → display offset in minutes (local solar time), or 0 for UTC. */
19
+ export function solarDisplayOffsetMinutes(useLocal: boolean, longitude: number | undefined): number {
20
+ if (!useLocal || longitude === undefined) return 0;
21
+ return Math.round((longitude / 15) * 60);
22
+ }
23
+
24
+ /** Epoch ms shifted into the display frame (UTC + offset). */
25
+ export function toSolarDisplay(ms: number, offsetMin: number): Date {
26
+ return new Date(ms + offsetMin * MS_PER_MIN);
27
+ }
28
+
29
+ /** "YYYY-MM-DD" of the display-frame day for an instant. */
30
+ export function toSolarDateInputValue(ms: number, offsetMin: number): string {
31
+ const d = toSolarDisplay(ms, offsetMin);
32
+ const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
33
+ const dd = String(d.getUTCDate()).padStart(2, '0');
34
+ return `${d.getUTCFullYear()}-${mm}-${dd}`;
35
+ }
36
+
37
+ /** Minutes since midnight in the display frame. */
38
+ export function solarMinutesOfDay(ms: number, offsetMin: number): number {
39
+ const d = toSolarDisplay(ms, offsetMin);
40
+ return d.getUTCHours() * 60 + d.getUTCMinutes();
41
+ }
42
+
43
+ /** Compose an absolute UTC epoch from a display-frame date string + minutes. */
44
+ export function composeSolarMs(dateStr: string, minutes: number, offsetMin: number): number {
45
+ const [y, m, d] = dateStr.split('-').map(Number);
46
+ const displayMs = Date.UTC(y, (m ?? 1) - 1, d ?? 1, Math.floor(minutes / 60), minutes % 60, 0);
47
+ return displayMs - offsetMin * MS_PER_MIN;
48
+ }
49
+
50
+ /** Epoch ms → "HH:MM" in the display frame. */
51
+ export function formatSolarTime(ms: number | null, offsetMin: number): string {
52
+ if (ms === null) return '—';
53
+ const d = toSolarDisplay(ms, offsetMin);
54
+ return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
55
+ }
package/src/main.tsx CHANGED
@@ -6,6 +6,11 @@
6
6
  * Application entry point
7
7
  */
8
8
 
9
+ // MUST be the first import: disables React 19.2's dev-mode component-render
10
+ // Performance tracking before react-dom caches `supportsUserTiming`, so large-IFC
11
+ // geometry/dataStore props don't blow its recursive prop-diff to a RangeError/OOM
12
+ // (the load "stops halfway" stall). See disable-react-dev-perf-track.ts.
13
+ import './disable-react-dev-perf-track';
9
14
  import React from 'react';
10
15
  import ReactDOM from 'react-dom/client';
11
16
  import { App } from './App';
@@ -38,6 +38,8 @@ import { createCompareSlice, type CompareSlice } from './slices/compareSlice.js'
38
38
  import { createScriptSlice, type ScriptSlice } from './slices/scriptSlice.js';
39
39
  import { createChatSlice, type ChatSlice } from './slices/chatSlice.js';
40
40
  import { createCesiumSlice, type CesiumSlice } from './slices/cesiumSlice.js';
41
+ import { createSolarSlice, type SolarSlice } from './slices/solarSlice.js';
42
+ import { createEnvironmentSlice, type EnvironmentSlice } from './slices/environmentSlice.js';
41
43
  import { createScheduleSlice, type ScheduleSlice } from './slices/scheduleSlice.js';
42
44
  import { createPlaybackSlice, type PlaybackSlice } from './slices/playbackSlice.js';
43
45
  import { createOverlaySlice, type OverlaySlice } from './slices/overlaySlice.js';
@@ -64,6 +66,7 @@ export { entityRefToString, stringToEntityRef, entityRefEquals, isIfcxDataStore
64
66
  // Re-export single source of truth for globalId → EntityRef resolution
65
67
  export { resolveEntityRef } from './resolveEntityRef.js';
66
68
  export { fromGlobalIdFromModels, toGlobalIdFromModels, toGlobalIdForRef } from './globalId.js';
69
+ export type { ForwardModelMapLike } from './globalId.js';
67
70
 
68
71
  // Re-export Drawing2D types
69
72
  export type { Drawing2DState, Drawing2DStatus, Annotation2DTool, PolygonArea2DResult, TextAnnotation2D, CloudAnnotation2D, SelectedAnnotation2D } from './slices/drawing2DSlice.js';
@@ -105,6 +108,7 @@ export {
105
108
  computeScheduleRange,
106
109
  computeHiddenProductIds,
107
110
  computeActiveProductIds,
111
+ countGeneratedTasks,
108
112
  taskStartEpoch,
109
113
  taskFinishEpoch,
110
114
  parseIsoDate,
@@ -135,6 +139,8 @@ export type ViewerState = LoadingSlice &
135
139
  ScriptSlice &
136
140
  ChatSlice &
137
141
  CesiumSlice &
142
+ SolarSlice &
143
+ EnvironmentSlice &
138
144
  ScheduleSlice &
139
145
  PlaybackSlice &
140
146
  OverlaySlice &
@@ -186,6 +192,8 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
186
192
  ...createScriptSlice(...args),
187
193
  ...createChatSlice(...args),
188
194
  ...createCesiumSlice(...args),
195
+ ...createSolarSlice(...args),
196
+ ...createEnvironmentSlice(...args),
189
197
  ...createScheduleSlice(...args),
190
198
  ...createPlaybackSlice(...args),
191
199
  ...createOverlaySlice(...args),
@@ -36,23 +36,7 @@ describe('AnnotationsSlice', () => {
36
36
  state = createAnnotationsSlice(setState as never, () => state, {} as never);
37
37
  });
38
38
 
39
- describe('initial state', () => {
40
- it('starts with no annotations and no draft', () => {
41
- assert.strictEqual(state.annotations.size, 0);
42
- assert.strictEqual(state.draft, null);
43
- assert.strictEqual(state.selectedAnnotationId, null);
44
- });
45
- });
46
-
47
39
  describe('beginDraft + commitDraft', () => {
48
- it('opens a draft at the given world position', () => {
49
- state.beginDraft({ x: 1, y: 2, z: 3 }, 42, 'arch');
50
- assert.ok(state.draft);
51
- assert.deepStrictEqual(state.draft!.position, { x: 1, y: 2, z: 3 });
52
- assert.strictEqual(state.draft!.entityExpressId, 42);
53
- assert.strictEqual(state.draft!.modelId, 'arch');
54
- });
55
-
56
40
  it('commits the draft into a new annotation', () => {
57
41
  state.beginDraft({ x: 1, y: 2, z: 3 }, 42, 'arch');
58
42
  const id = state.commitDraft('Defect: chip in the corner');
@@ -19,7 +19,7 @@ import type { MapConversion } from '@ifc-lite/parser';
19
19
 
20
20
  import { clearTerrainElevationCache } from '@/lib/geo/terrain-elevation';
21
21
 
22
- export type CesiumDataSource = 'google-photorealistic';
22
+ export type CesiumDataSource = 'google-photorealistic' | 'osm-buildings';
23
23
 
24
24
  export interface CesiumPlacementDraft {
25
25
  eastings: number;
@@ -133,8 +133,8 @@ function saveToStorage(key: string, value: string): void {
133
133
  }
134
134
 
135
135
  function loadDataSource(): CesiumDataSource {
136
- // Only Google Photorealistic is supported; upgrade any stale stored value.
137
- return 'google-photorealistic';
136
+ const stored = loadFromStorage(STORAGE_KEY_DATA_SOURCE, 'google-photorealistic');
137
+ return stored === 'osm-buildings' ? 'osm-buildings' : 'google-photorealistic';
138
138
  }
139
139
 
140
140
  /** Resolve the Cesium ion token: user override > build-time default */
@@ -53,46 +53,6 @@ describe('DataSlice', () => {
53
53
  state = { ...slice, activeModelId: null, models: new Map() };
54
54
  });
55
55
 
56
- describe('initial state', () => {
57
- it('should have null ifcDataStore', () => {
58
- assert.strictEqual(state.ifcDataStore, null);
59
- });
60
-
61
- it('should have null geometryResult', () => {
62
- assert.strictEqual(state.geometryResult, null);
63
- });
64
-
65
- it('should have null pendingColorUpdates', () => {
66
- assert.strictEqual(state.pendingColorUpdates, null);
67
- });
68
-
69
- it('should have null pendingMeshColorUpdates', () => {
70
- assert.strictEqual(state.pendingMeshColorUpdates, null);
71
- });
72
- });
73
-
74
- describe('setIfcDataStore', () => {
75
- it('should set the data store', () => {
76
- const mockStore = { entityCount: 100 } as any;
77
- state.setIfcDataStore(mockStore);
78
- assert.strictEqual(state.ifcDataStore, mockStore);
79
- });
80
-
81
- it('should allow setting to null', () => {
82
- state.setIfcDataStore({ entityCount: 100 } as any);
83
- state.setIfcDataStore(null);
84
- assert.strictEqual(state.ifcDataStore, null);
85
- });
86
- });
87
-
88
- describe('setGeometryResult', () => {
89
- it('should set the geometry result', () => {
90
- const mockResult = { meshes: [], totalTriangles: 0, totalVertices: 0 } as any;
91
- state.setGeometryResult(mockResult);
92
- assert.strictEqual(state.geometryResult, mockResult);
93
- });
94
- });
95
-
96
56
  describe('appendGeometryBatch', () => {
97
57
  it('should create new geometry result when none exists', () => {
98
58
  const meshes = [createMockMesh(1), createMockMesh(2)];
@@ -0,0 +1,101 @@
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
+ * Environment (sky + lighting) state slice.
7
+ *
8
+ * Owns the user's lighting choices for BOTH rendering paths:
9
+ * • WebGPU viewport — preset lighting + procedural sky pass, composed into
10
+ * `RenderOptions.environment` by Viewport.
11
+ * • Cesium geo mode — the same sky toggle drives `scene.skyAtmosphere` /
12
+ * `scene.sun` / fog in CesiumOverlay, so "Sky" means the same thing in
13
+ * whichever mode is active.
14
+ *
15
+ * Preset/sky/exposure choices persist in localStorage; panel visibility is
16
+ * session-only.
17
+ */
18
+
19
+ import type { StateCreator } from 'zustand';
20
+ import { isLightingPresetId, type LightingPresetId } from '@/lib/lighting-presets';
21
+
22
+ export interface EnvironmentSlice {
23
+ /** Active lighting preset for the WebGPU viewport. */
24
+ envPreset: LightingPresetId;
25
+ /**
26
+ * Cesium geo mode: show the atmosphere, sun disc and fog. (Standalone the
27
+ * sky comes with the lighting preset — every preset except `default`
28
+ * enables it — so this flag only drives the world-context scene.)
29
+ */
30
+ envSkyEnabled: boolean;
31
+ /** User exposure trim, multiplied onto the preset exposure. 1 = neutral. */
32
+ envExposure: number;
33
+ /** Whether the Sun & Sky panel is open. */
34
+ envPanelOpen: boolean;
35
+
36
+ setEnvPreset: (preset: LightingPresetId) => void;
37
+ setEnvSkyEnabled: (enabled: boolean) => void;
38
+ setEnvExposure: (exposure: number) => void;
39
+ setEnvPanelOpen: (open: boolean) => void;
40
+ toggleEnvPanel: () => void;
41
+ }
42
+
43
+ const STORAGE_KEY = 'ifc-lite:environment';
44
+
45
+ interface PersistedEnvironment {
46
+ preset?: string;
47
+ skyEnabled?: boolean;
48
+ exposure?: number;
49
+ }
50
+
51
+ function loadPersisted(): PersistedEnvironment {
52
+ try {
53
+ const raw = localStorage.getItem(STORAGE_KEY);
54
+ if (!raw) return {};
55
+ const parsed: unknown = JSON.parse(raw);
56
+ return typeof parsed === 'object' && parsed !== null ? (parsed as PersistedEnvironment) : {};
57
+ } catch {
58
+ return {};
59
+ }
60
+ }
61
+
62
+ function persist(state: Pick<EnvironmentSlice, 'envPreset' | 'envSkyEnabled' | 'envExposure'>): void {
63
+ try {
64
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
65
+ preset: state.envPreset,
66
+ skyEnabled: state.envSkyEnabled,
67
+ exposure: state.envExposure,
68
+ } satisfies PersistedEnvironment));
69
+ } catch { /* storage unavailable */ }
70
+ }
71
+
72
+ function clampExposure(value: number): number {
73
+ if (!Number.isFinite(value)) return 1;
74
+ return Math.min(2, Math.max(0.4, value));
75
+ }
76
+
77
+ export const createEnvironmentSlice: StateCreator<EnvironmentSlice, [], [], EnvironmentSlice> = (set, get) => {
78
+ const stored = loadPersisted();
79
+ const initial = {
80
+ envPreset: (stored.preset && isLightingPresetId(stored.preset) ? stored.preset : 'default') as LightingPresetId,
81
+ envSkyEnabled: stored.skyEnabled === true,
82
+ envExposure: clampExposure(stored.exposure ?? 1),
83
+ };
84
+
85
+ const update = (patch: Partial<EnvironmentSlice>) => {
86
+ set(patch);
87
+ const s = get();
88
+ persist(s);
89
+ };
90
+
91
+ return {
92
+ ...initial,
93
+ envPanelOpen: false,
94
+
95
+ setEnvPreset: (preset) => update({ envPreset: preset }),
96
+ setEnvSkyEnabled: (enabled) => update({ envSkyEnabled: enabled }),
97
+ setEnvExposure: (exposure) => update({ envExposure: clampExposure(exposure) }),
98
+ setEnvPanelOpen: (open) => set({ envPanelOpen: open }),
99
+ toggleEnvPanel: () => set((s) => ({ envPanelOpen: !s.envPanelOpen })),
100
+ };
101
+ };
@@ -257,7 +257,12 @@ export const createIdsSlice: StateCreator<IDSSlice, [], [], IDSSlice> = (set, ge
257
257
 
258
258
  setIdsLoading: (idsLoading) => set({ idsLoading }),
259
259
 
260
- setIdsError: (idsError) => set({ idsError, idsLoading: false }),
260
+ // Setting an error ends the run; but CLEARING the error (idsError =
261
+ // null, e.g. at the start of a validation run) must NOT flip loading
262
+ // off — doing so kept the progress UI, which is gated on `loading`,
263
+ // hidden for the entire run even though progress was streaming in.
264
+ setIdsError: (idsError) =>
265
+ set(idsError !== null ? { idsError, idsLoading: false } : { idsError }),
261
266
 
262
267
  setIdsLocale: (idsLocale) => set({ idsLocale }),
263
268
 
@@ -24,28 +24,7 @@ describe('SelectionSlice', () => {
24
24
  state = createSelectionSlice(setState, () => state, {} as any);
25
25
  });
26
26
 
27
- describe('initial state', () => {
28
- it('should have null selectedEntity', () => {
29
- assert.strictEqual(state.selectedEntity, null);
30
- });
31
-
32
- it('should have empty selectedEntitiesSet', () => {
33
- assert.strictEqual(state.selectedEntitiesSet.size, 0);
34
- });
35
-
36
- it('should have null selectedEntityId (legacy)', () => {
37
- assert.strictEqual(state.selectedEntityId, null);
38
- });
39
- });
40
-
41
27
  describe('multi-model selection: setSelectedEntity', () => {
42
- it('should set primary selection with model context', () => {
43
- const ref: EntityRef = { modelId: 'model-1', expressId: 123 };
44
- state.setSelectedEntity(ref);
45
-
46
- assert.deepStrictEqual(state.selectedEntity, ref);
47
- });
48
-
49
28
  it('should NOT update selectedEntityId (caller must use setSelectedEntityId for global ID)', () => {
50
29
  // NOTE: selectedEntityId holds the GLOBAL ID for renderer highlighting,
51
30
  // while selectedEntity.expressId holds the ORIGINAL express ID for property lookup.
@@ -56,15 +35,6 @@ describe('SelectionSlice', () => {
56
35
  // selectedEntityId should remain null - caller must set it separately with globalId
57
36
  assert.strictEqual(state.selectedEntityId, null);
58
37
  });
59
-
60
- it('should allow clearing selection with null', () => {
61
- const ref: EntityRef = { modelId: 'model-1', expressId: 123 };
62
- state.setSelectedEntity(ref);
63
- state.setSelectedEntity(null);
64
-
65
- assert.strictEqual(state.selectedEntity, null);
66
- assert.strictEqual(state.selectedEntityId, null);
67
- });
68
38
  });
69
39
 
70
40
  describe('multi-model selection: addEntityToSelection', () => {
@@ -285,19 +255,6 @@ describe('SelectionSlice', () => {
285
255
  });
286
256
  });
287
257
 
288
- describe('legacy selection: setSelectedEntityId', () => {
289
- it('should set legacy selectedEntityId', () => {
290
- state.setSelectedEntityId(123);
291
- assert.strictEqual(state.selectedEntityId, 123);
292
- });
293
-
294
- it('should allow clearing with null', () => {
295
- state.setSelectedEntityId(123);
296
- state.setSelectedEntityId(null);
297
- assert.strictEqual(state.selectedEntityId, null);
298
- });
299
- });
300
-
301
258
  describe('legacy selection: storey selection', () => {
302
259
  it('should toggle storey selection', () => {
303
260
  state.toggleStoreySelection(1);
@@ -0,0 +1,121 @@
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 study state slice — drives the georeferenced 3D sun-path dome and
7
+ * shadow study rendered in the Cesium context overlay.
8
+ *
9
+ * The slice itself only owns *intent* (which instant to study, what to show).
10
+ * The heavy lifting — computing the sun direction, enabling Cesium's sun /
11
+ * shadows, and building the dome geometry — happens in CesiumOverlay, which
12
+ * reads this state and writes the resolved {@link SolarSunInfo} back here for
13
+ * the readout panel.
14
+ *
15
+ * Solar study requires Cesium (it renders the model + OSM context with mutual
16
+ * shadows), so the UI turns Cesium on when the study is enabled.
17
+ */
18
+
19
+ import type { StateCreator } from 'zustand';
20
+
21
+ /** Resolved solar readout for the currently studied instant + site. */
22
+ export interface SolarSunInfo {
23
+ /** Site latitude in degrees (north positive), from the model georeference. */
24
+ latitude: number;
25
+ /** Site longitude in degrees (east positive). */
26
+ longitude: number;
27
+ /** Sun azimuth in degrees clockwise from true north. */
28
+ azimuth: number;
29
+ /** Sun altitude in degrees above the horizon (negative below). */
30
+ altitude: number;
31
+ /** Sunrise epoch ms, or null (polar night / midnight sun). */
32
+ sunriseMs: number | null;
33
+ /** Sunset epoch ms, or null. */
34
+ sunsetMs: number | null;
35
+ /** Solar-noon epoch ms. */
36
+ solarNoonMs: number;
37
+ }
38
+
39
+ /** Which dimension the animation sweep advances. */
40
+ export type SolarSweepMode = 'day' | 'year';
41
+
42
+ export interface SolarSlice {
43
+ /** Whether the sun-path / shadow study is active. */
44
+ solarEnabled: boolean;
45
+ /** Studied instant as epoch milliseconds (UTC). */
46
+ solarDateMs: number;
47
+ /** Draw the 3D sun-path dome (analemmas, day arc, graticule). */
48
+ solarShowSunPath: boolean;
49
+ /** Render real cast shadows (model ↔ OSM context) via Cesium. */
50
+ solarShowShadows: boolean;
51
+ /** Resolved sun position/times for the readout panel; null until computed. */
52
+ solarSunInfo: SolarSunInfo | null;
53
+ /**
54
+ * Unit vector toward the sun in viewer/world space (Y-up), derived from the
55
+ * studied instant + site georeference. Drives the WebGPU renderer's sun
56
+ * (lighting + procedural sky) so daylight reads identically with and
57
+ * without the Cesium context. Null when the study is off or the site is
58
+ * unknown.
59
+ */
60
+ solarSunDirection: [number, number, number] | null;
61
+ /**
62
+ * Display times in local solar time derived from the site longitude
63
+ * (15°/hour) instead of UTC. This is civil-timezone-agnostic on purpose —
64
+ * sun-path studies care about solar time, and a longitude offset needs no
65
+ * timezone database or DST rules.
66
+ */
67
+ solarUseLocalTime: boolean;
68
+ /** Whether the animation sweep is playing. */
69
+ solarPlaying: boolean;
70
+ /** Which dimension the sweep advances (time-of-day vs day-of-year). */
71
+ solarSweepMode: SolarSweepMode;
72
+
73
+ setSolarEnabled: (enabled: boolean) => void;
74
+ toggleSolar: () => void;
75
+ setSolarDateMs: (ms: number) => void;
76
+ setSolarShowSunPath: (show: boolean) => void;
77
+ setSolarShowShadows: (show: boolean) => void;
78
+ setSolarSunInfo: (info: SolarSunInfo | null) => void;
79
+ setSolarSunDirection: (dir: [number, number, number] | null) => void;
80
+ setSolarUseLocalTime: (use: boolean) => void;
81
+ setSolarPlaying: (playing: boolean) => void;
82
+ toggleSolarPlaying: () => void;
83
+ setSolarSweepMode: (mode: SolarSweepMode) => void;
84
+ }
85
+
86
+ /** Default studied instant: a bright equinox midday so the dome reads well. */
87
+ function defaultSolarDateMs(): number {
88
+ return Date.UTC(new Date().getUTCFullYear(), 2, 20, 12, 0, 0);
89
+ }
90
+
91
+ export const createSolarSlice: StateCreator<SolarSlice, [], [], SolarSlice> = (set) => ({
92
+ solarEnabled: false,
93
+ solarDateMs: defaultSolarDateMs(),
94
+ solarShowSunPath: true,
95
+ solarShowShadows: true,
96
+ solarSunInfo: null,
97
+ solarSunDirection: null,
98
+ // Default to the site's local solar time (derived from longitude): for a
99
+ // sun-path study "9am" should mean 9am at the site, not UTC.
100
+ solarUseLocalTime: true,
101
+ solarPlaying: false,
102
+ solarSweepMode: 'day',
103
+
104
+ setSolarEnabled: (enabled) =>
105
+ set(enabled
106
+ ? { solarEnabled: true }
107
+ : { solarEnabled: false, solarSunInfo: null, solarSunDirection: null, solarPlaying: false }),
108
+ toggleSolar: () =>
109
+ set((s) => (s.solarEnabled
110
+ ? { solarEnabled: false, solarSunInfo: null, solarSunDirection: null, solarPlaying: false }
111
+ : { solarEnabled: true })),
112
+ setSolarDateMs: (ms) => set({ solarDateMs: ms }),
113
+ setSolarShowSunPath: (show) => set({ solarShowSunPath: show }),
114
+ setSolarShowShadows: (show) => set({ solarShowShadows: show }),
115
+ setSolarSunInfo: (info) => set({ solarSunInfo: info }),
116
+ setSolarSunDirection: (dir) => set({ solarSunDirection: dir }),
117
+ setSolarUseLocalTime: (use) => set({ solarUseLocalTime: use }),
118
+ setSolarPlaying: (playing) => set({ solarPlaying: playing }),
119
+ toggleSolarPlaying: () => set((s) => ({ solarPlaying: !s.solarPlaying })),
120
+ setSolarSweepMode: (mode) => set({ solarSweepMode: mode }),
121
+ });
@@ -25,14 +25,6 @@ describe('VisibilitySlice', () => {
25
25
  });
26
26
 
27
27
  describe('initial state', () => {
28
- it('should have empty hiddenEntitiesByModel', () => {
29
- assert.strictEqual(state.hiddenEntitiesByModel.size, 0);
30
- });
31
-
32
- it('should have empty isolatedEntitiesByModel', () => {
33
- assert.strictEqual(state.isolatedEntitiesByModel.size, 0);
34
- });
35
-
36
28
  it('should initialise type visibility from persisted preferences', () => {
37
29
  const persisted = getPersistedTypeVisibility();
38
30
  assert.strictEqual(state.typeVisibility.spaces, persisted.spaces);
@@ -44,14 +36,6 @@ describe('VisibilitySlice', () => {
44
36
  });
45
37
 
46
38
  describe('multi-model visibility: hideEntityInModel', () => {
47
- it('should hide entity in specific model', () => {
48
- state.hideEntityInModel('model-1', 123);
49
-
50
- const hidden = state.hiddenEntitiesByModel.get('model-1');
51
- assert.ok(hidden);
52
- assert.ok(hidden.has(123));
53
- });
54
-
55
39
  it('should create new set for model if not exists', () => {
56
40
  state.hideEntityInModel('model-1', 100);
57
41
  state.hideEntityInModel('model-1', 200);
@@ -209,13 +193,6 @@ describe('VisibilitySlice', () => {
209
193
  });
210
194
  });
211
195
 
212
- describe('legacy visibility: hideEntity', () => {
213
- it('should hide entity', () => {
214
- state.hideEntity(123);
215
- assert.ok(state.hiddenEntities.has(123));
216
- });
217
- });
218
-
219
196
  describe('legacy visibility: showEntity', () => {
220
197
  it('should show hidden entity', () => {
221
198
  state.hideEntity(123);
@@ -286,28 +263,21 @@ describe('VisibilitySlice', () => {
286
263
  });
287
264
 
288
265
  describe('type visibility: toggleTypeVisibility', () => {
289
- it('should toggle spaces visibility', () => {
290
- const initial = state.typeVisibility.spaces;
291
- state.toggleTypeVisibility('spaces');
292
- assert.strictEqual(state.typeVisibility.spaces, !initial);
293
- });
294
-
295
- it('should toggle openings visibility', () => {
296
- const initial = state.typeVisibility.openings;
297
- state.toggleTypeVisibility('openings');
298
- assert.strictEqual(state.typeVisibility.openings, !initial);
299
- });
300
-
301
- it('should toggle site visibility', () => {
302
- const initial = state.typeVisibility.site;
303
- state.toggleTypeVisibility('site');
304
- assert.strictEqual(state.typeVisibility.site, !initial);
305
- });
306
-
307
- it('should toggle ifcAnnotations visibility', () => {
308
- const initial = state.typeVisibility.ifcAnnotations;
309
- state.toggleTypeVisibility('ifcAnnotations');
310
- assert.strictEqual(state.typeVisibility.ifcAnnotations, !initial);
266
+ it('should toggle each type key independently', () => {
267
+ const keys = ['spaces', 'openings', 'site', 'ifcAnnotations', 'ifcGrid'] as const;
268
+ for (const key of keys) {
269
+ const before = { ...state.typeVisibility };
270
+ state.toggleTypeVisibility(key);
271
+ assert.strictEqual(state.typeVisibility[key], !before[key], `toggle ${key}`);
272
+ for (const other of keys) {
273
+ if (other === key) continue;
274
+ assert.strictEqual(
275
+ state.typeVisibility[other],
276
+ before[other],
277
+ `toggling ${key} must not change ${other}`,
278
+ );
279
+ }
280
+ }
311
281
  });
312
282
 
313
283
  it('resetTypeVisibility restores semantic defaults', () => {
@@ -13,7 +13,7 @@
13
13
  import type { MeshData } from '@ifc-lite/geometry';
14
14
  import type { IfcDataStore } from '@ifc-lite/parser';
15
15
  import { buildSpatialIndexAsync } from '@ifc-lite/spatial';
16
- import { useViewerStore } from '../store.js';
16
+ import { useViewerStore } from '../store/index.js';
17
17
 
18
18
  /**
19
19
  * Build a spatial index in the background (time-sliced, non-blocking)