@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
package/src/lib/recent-files.ts
CHANGED
|
@@ -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
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
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';
|
package/src/store/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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)
|