@ifc-lite/viewer 1.0.0 → 1.1.1

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.
@@ -4,6 +4,7 @@
4
4
 
5
5
  /**
6
6
  * Hook for loading and processing IFC files
7
+ * Includes binary cache support for fast subsequent loads
7
8
  */
8
9
 
9
10
  import { useMemo, useCallback, useRef } from 'react';
@@ -13,6 +14,17 @@ import { GeometryProcessor, GeometryQuality, type MeshData } from '@ifc-lite/geo
13
14
  import { IfcQuery } from '@ifc-lite/query';
14
15
  import { BufferBuilder } from '@ifc-lite/geometry';
15
16
  import { buildSpatialIndex } from '@ifc-lite/spatial';
17
+ import {
18
+ BinaryCacheWriter,
19
+ BinaryCacheReader,
20
+ xxhash64Hex,
21
+ type IfcDataStore as CacheDataStore,
22
+ type GeometryData,
23
+ } from '@ifc-lite/cache';
24
+ import { getCached, setCached } from '../services/ifc-cache.js';
25
+
26
+ // Minimum file size to cache (10MB) - smaller files parse quickly anyway
27
+ const CACHE_SIZE_THRESHOLD = 10 * 1024 * 1024;
16
28
 
17
29
  export function useIfc() {
18
30
  const {
@@ -33,19 +45,160 @@ export function useIfc() {
33
45
  // Track if we've already logged for this ifcDataStore
34
46
  const lastLoggedDataStoreRef = useRef<typeof ifcDataStore>(null);
35
47
 
48
+ /**
49
+ * Load from binary cache (INSTANT path)
50
+ * Key optimizations:
51
+ * 1. Single setGeometryResult call instead of batched appendGeometryBatch
52
+ * 2. Build spatial index in requestIdleCallback (non-blocking)
53
+ */
54
+ const loadFromCache = useCallback(async (
55
+ cacheBuffer: ArrayBuffer,
56
+ fileName: string
57
+ ): Promise<boolean> => {
58
+ try {
59
+ console.time('[useIfc] cache-load');
60
+ setProgress({ phase: 'Loading from cache', percent: 10 });
61
+
62
+ // IMPORTANT: Reset geometry first so Viewport detects this as a new file
63
+ // This ensures camera fitting and bounds are properly reset
64
+ setGeometryResult(null);
65
+
66
+ const reader = new BinaryCacheReader();
67
+ const result = await reader.read(cacheBuffer);
68
+
69
+ // Convert cache data store to viewer data store format
70
+ const dataStore = result.dataStore as any;
71
+
72
+ if (result.geometry) {
73
+ const { meshes, coordinateInfo, totalVertices, totalTriangles } = result.geometry;
74
+
75
+ // INSTANT: Set ALL geometry in ONE state update (no batching!)
76
+ setGeometryResult({
77
+ meshes,
78
+ totalVertices,
79
+ totalTriangles,
80
+ coordinateInfo,
81
+ });
82
+
83
+ // Set data store
84
+ setIfcDataStore(dataStore);
85
+
86
+ // Build spatial index in background (non-blocking)
87
+ if (meshes.length > 0) {
88
+ // Use requestIdleCallback for non-blocking BVH build
89
+ const buildIndex = () => {
90
+ console.time('[useIfc] spatial-index-background');
91
+ try {
92
+ const spatialIndex = buildSpatialIndex(meshes);
93
+ dataStore.spatialIndex = spatialIndex;
94
+ // Update store with spatial index (doesn't affect rendering)
95
+ setIfcDataStore({ ...dataStore });
96
+ console.timeEnd('[useIfc] spatial-index-background');
97
+ } catch (err) {
98
+ console.warn('[useIfc] Failed to build spatial index:', err);
99
+ }
100
+ };
101
+
102
+ // Schedule for idle time, or fallback to setTimeout
103
+ if ('requestIdleCallback' in window) {
104
+ (window as any).requestIdleCallback(buildIndex, { timeout: 1000 });
105
+ } else {
106
+ setTimeout(buildIndex, 100);
107
+ }
108
+ }
109
+ } else {
110
+ setIfcDataStore(dataStore);
111
+ }
112
+
113
+ setProgress({ phase: 'Complete (from cache)', percent: 100 });
114
+ console.timeEnd('[useIfc] cache-load');
115
+ console.log(`[useIfc] INSTANT load: ${fileName} from cache (${result.geometry?.meshes.length || 0} meshes)`);
116
+
117
+ return true;
118
+ } catch (err) {
119
+ console.error('[useIfc] Failed to load from cache:', err);
120
+ return false;
121
+ }
122
+ }, [setProgress, setIfcDataStore, setGeometryResult]);
123
+
124
+ /**
125
+ * Save to binary cache (background operation)
126
+ */
127
+ const saveToCache = useCallback(async (
128
+ cacheKey: string,
129
+ dataStore: any,
130
+ geometry: GeometryData,
131
+ sourceBuffer: ArrayBuffer,
132
+ fileName: string
133
+ ): Promise<void> => {
134
+ try {
135
+ console.time('[useIfc] cache-write');
136
+
137
+ const writer = new BinaryCacheWriter();
138
+
139
+ // Adapt dataStore to cache format
140
+ const cacheDataStore: CacheDataStore = {
141
+ schema: dataStore.schemaVersion === 'IFC4' ? 1 : dataStore.schemaVersion === 'IFC4X3' ? 2 : 0,
142
+ entityCount: dataStore.entityCount || dataStore.entities?.count || 0,
143
+ strings: dataStore.strings,
144
+ entities: dataStore.entities,
145
+ properties: dataStore.properties,
146
+ quantities: dataStore.quantities,
147
+ relationships: dataStore.relationships,
148
+ spatialHierarchy: dataStore.spatialHierarchy,
149
+ };
150
+
151
+ const cacheBuffer = await writer.write(
152
+ cacheDataStore,
153
+ geometry,
154
+ sourceBuffer,
155
+ { includeGeometry: true }
156
+ );
157
+
158
+ await setCached(cacheKey, cacheBuffer, fileName, sourceBuffer.byteLength);
159
+
160
+ console.timeEnd('[useIfc] cache-write');
161
+ console.log(`[useIfc] Cached ${fileName} (${(cacheBuffer.byteLength / 1024 / 1024).toFixed(2)}MB cache)`);
162
+ } catch (err) {
163
+ console.warn('[useIfc] Failed to cache model:', err);
164
+ }
165
+ }, []);
166
+
36
167
  const loadFile = useCallback(async (file: File) => {
37
168
  const { resetViewerState } = useViewerStore.getState();
38
-
169
+
39
170
  try {
40
171
  // Reset all viewer state before loading new file
41
172
  resetViewerState();
42
-
173
+
43
174
  setLoading(true);
44
175
  setError(null);
45
176
  setProgress({ phase: 'Loading file', percent: 0 });
46
177
 
47
178
  // Read file
48
179
  const buffer = await file.arrayBuffer();
180
+ const fileSizeMB = buffer.byteLength / (1024 * 1024);
181
+
182
+ // Compute cache key (hash of file content)
183
+ setProgress({ phase: 'Checking cache', percent: 5 });
184
+ const cacheKey = xxhash64Hex(buffer);
185
+ console.log(`[useIfc] File: ${file.name}, size: ${fileSizeMB.toFixed(2)}MB, hash: ${cacheKey}`);
186
+
187
+ // Try to load from cache first (only for files above threshold)
188
+ if (buffer.byteLength >= CACHE_SIZE_THRESHOLD) {
189
+ const cachedBuffer = await getCached(cacheKey);
190
+ if (cachedBuffer) {
191
+ const success = await loadFromCache(cachedBuffer, file.name);
192
+ if (success) {
193
+ setLoading(false);
194
+ return;
195
+ }
196
+ // Cache load failed, fall through to normal parsing
197
+ console.log('[useIfc] Cache load failed, falling back to parsing');
198
+ }
199
+ }
200
+
201
+ // Cache miss or small file - parse normally
49
202
  setProgress({ phase: 'Parsing IFC', percent: 10 });
50
203
 
51
204
  // Parse IFC using columnar parser
@@ -78,64 +231,126 @@ export function useIfc() {
78
231
  }
79
232
  }
80
233
 
81
- // Use streaming processing for progressive rendering
82
- const bufferBuilder = new BufferBuilder();
234
+ // Use adaptive processing: sync for small files, streaming for large files
83
235
  let estimatedTotal = 0;
84
236
  let totalMeshes = 0;
85
237
  const allMeshes: MeshData[] = []; // Collect all meshes for BVH building
238
+ let finalCoordinateInfo: any = null;
86
239
 
87
240
  // Clear existing geometry result
88
241
  setGeometryResult(null);
89
242
 
243
+ // Timing instrumentation
244
+ const processingStart = performance.now();
245
+ let batchCount = 0;
246
+ let lastBatchTime = processingStart;
247
+ let totalWaitTime = 0; // Time waiting for WASM to yield batches
248
+ let totalProcessTime = 0; // Time processing batches in JS
249
+
90
250
  try {
91
- for await (const event of geometryProcessor.processStreaming(new Uint8Array(buffer), entityIndexMap, 100)) {
251
+ console.log(`[useIfc] Starting adaptive processing (file size: ${fileSizeMB.toFixed(2)}MB)...`);
252
+ console.time('[useIfc] total-processing');
253
+
254
+ for await (const event of geometryProcessor.processAdaptive(new Uint8Array(buffer), {
255
+ sizeThreshold: 2 * 1024 * 1024, // 2MB threshold
256
+ batchSize: 25,
257
+ entityIndex: entityIndexMap,
258
+ })) {
259
+ const eventReceived = performance.now();
260
+ const waitTime = eventReceived - lastBatchTime;
261
+
92
262
  switch (event.type) {
93
263
  case 'start':
94
264
  estimatedTotal = event.totalEstimate;
265
+ console.log(`[useIfc] Processing started, estimated: ${estimatedTotal}`);
95
266
  break;
96
267
  case 'model-open':
97
268
  setProgress({ phase: 'Processing geometry', percent: 50 });
269
+ console.log(`[useIfc] Model opened at ${(eventReceived - processingStart).toFixed(0)}ms`);
98
270
  break;
99
- case 'batch':
271
+ case 'batch': {
272
+ batchCount++;
273
+ totalWaitTime += waitTime;
274
+
275
+ const processStart = performance.now();
276
+
100
277
  // Collect meshes for BVH building
101
278
  allMeshes.push(...event.meshes);
102
-
103
- // Convert MeshData[] to GPU-ready format and append
104
- const gpuMeshes = bufferBuilder.processMeshes(event.meshes).meshes;
105
- appendGeometryBatch(gpuMeshes, event.coordinateInfo);
279
+ finalCoordinateInfo = event.coordinateInfo;
280
+
281
+ // Append mesh batch to store (triggers React re-render)
282
+ appendGeometryBatch(event.meshes, event.coordinateInfo);
106
283
  totalMeshes = event.totalSoFar;
107
284
 
108
285
  // Update progress (50-95% for geometry processing)
109
- const progressPercent = 50 + Math.min(45, (totalMeshes / Math.max(estimatedTotal, totalMeshes)) * 45);
286
+ const progressPercent = 50 + Math.min(45, (totalMeshes / Math.max(estimatedTotal / 10, totalMeshes)) * 45);
110
287
  setProgress({
111
288
  phase: `Rendering geometry (${totalMeshes} meshes)`,
112
289
  percent: progressPercent
113
290
  });
291
+
292
+ const processTime = performance.now() - processStart;
293
+ totalProcessTime += processTime;
294
+
295
+ // Log batch timing (first 5, then every 10th)
296
+ if (batchCount <= 5 || batchCount % 10 === 0) {
297
+ console.log(
298
+ `[useIfc] Batch #${batchCount}: ${event.meshes.length} meshes, ` +
299
+ `wait: ${waitTime.toFixed(0)}ms, process: ${processTime.toFixed(0)}ms, ` +
300
+ `total: ${totalMeshes} meshes at ${(eventReceived - processingStart).toFixed(0)}ms`
301
+ );
302
+ }
114
303
  break;
304
+ }
115
305
  case 'complete':
306
+ console.log(
307
+ `[useIfc] Processing complete: ${batchCount} batches, ${event.totalMeshes} meshes\n` +
308
+ ` Total wait (WASM): ${totalWaitTime.toFixed(0)}ms\n` +
309
+ ` Total process (JS): ${totalProcessTime.toFixed(0)}ms\n` +
310
+ ` First batch at: ${batchCount > 0 ? '(see Batch #1 above)' : 'N/A'}`
311
+ );
312
+ console.timeEnd('[useIfc] total-processing');
313
+
314
+ finalCoordinateInfo = event.coordinateInfo;
315
+
116
316
  // Update geometry result with final coordinate info
117
317
  updateCoordinateInfo(event.coordinateInfo);
118
-
119
- // Build spatial index from all collected meshes
318
+
319
+ // Build spatial index from meshes
120
320
  if (allMeshes.length > 0) {
121
321
  setProgress({ phase: 'Building spatial index', percent: 95 });
322
+ console.time('[useIfc] spatial-index');
122
323
  try {
123
324
  const spatialIndex = buildSpatialIndex(allMeshes);
124
- // Attach spatial index to dataStore
125
325
  (dataStore as any).spatialIndex = spatialIndex;
126
- setIfcDataStore(dataStore); // Update store with spatial index
326
+ setIfcDataStore(dataStore);
327
+ console.timeEnd('[useIfc] spatial-index');
127
328
  } catch (err) {
329
+ console.timeEnd('[useIfc] spatial-index');
128
330
  console.warn('[useIfc] Failed to build spatial index:', err);
129
- // Continue without spatial index - it's optional
130
331
  }
131
332
  }
132
-
333
+
133
334
  setProgress({ phase: 'Complete', percent: 100 });
335
+
336
+ // Cache the result in the background (for files above threshold)
337
+ if (buffer.byteLength >= CACHE_SIZE_THRESHOLD && allMeshes.length > 0 && finalCoordinateInfo) {
338
+ // Don't await - let it run in background
339
+ const geometryData: GeometryData = {
340
+ meshes: allMeshes,
341
+ totalVertices: allMeshes.reduce((sum, m) => sum + m.positions.length / 3, 0),
342
+ totalTriangles: allMeshes.reduce((sum, m) => sum + m.indices.length / 3, 0),
343
+ coordinateInfo: finalCoordinateInfo,
344
+ };
345
+ saveToCache(cacheKey, dataStore, geometryData, buffer, file.name);
346
+ }
134
347
  break;
135
348
  }
349
+
350
+ lastBatchTime = performance.now();
136
351
  }
137
352
  } catch (err) {
138
- console.error('[useIfc] Error in streaming processing:', err);
353
+ console.error('[useIfc] Error in processing:', err);
139
354
  setError(err instanceof Error ? err.message : 'Unknown error during geometry processing');
140
355
  }
141
356
 
@@ -144,12 +359,12 @@ export function useIfc() {
144
359
  setError(err instanceof Error ? err.message : 'Unknown error');
145
360
  setLoading(false);
146
361
  }
147
- }, [setLoading, setError, setProgress, setIfcDataStore, setGeometryResult, appendGeometryBatch, updateCoordinateInfo]);
362
+ }, [setLoading, setError, setProgress, setIfcDataStore, setGeometryResult, appendGeometryBatch, updateCoordinateInfo, loadFromCache, saveToCache]);
148
363
 
149
364
  // Memoize query to prevent recreation on every render
150
365
  const query = useMemo(() => {
151
366
  if (!ifcDataStore) return null;
152
-
367
+
153
368
  // Only log once per ifcDataStore
154
369
  lastLoggedDataStoreRef.current = ifcDataStore;
155
370
 
@@ -0,0 +1,251 @@
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
+ * IndexedDB cache service for IFC files
7
+ *
8
+ * Stores parsed IFC data and geometry in IndexedDB for fast subsequent loads.
9
+ * Uses xxhash64 of the source file as the cache key.
10
+ */
11
+
12
+ const DB_NAME = 'ifc-lite-cache';
13
+ const DB_VERSION = 1;
14
+ const STORE_NAME = 'models';
15
+
16
+ interface CacheEntry {
17
+ key: string;
18
+ buffer: ArrayBuffer;
19
+ fileName: string;
20
+ fileSize: number;
21
+ createdAt: number;
22
+ }
23
+
24
+ let dbPromise: Promise<IDBDatabase> | null = null;
25
+
26
+ /**
27
+ * Open the IndexedDB database
28
+ */
29
+ function openDatabase(): Promise<IDBDatabase> {
30
+ if (dbPromise) return dbPromise;
31
+
32
+ dbPromise = new Promise((resolve, reject) => {
33
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
34
+
35
+ request.onerror = () => {
36
+ console.error('[IFC Cache] Failed to open database:', request.error);
37
+ dbPromise = null; // Reset so we can retry
38
+ reject(request.error);
39
+ };
40
+
41
+ request.onsuccess = () => {
42
+ const db = request.result;
43
+
44
+ // Verify the object store exists (handles corrupted DB state)
45
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
46
+ console.warn('[IFC Cache] Object store missing, recreating database...');
47
+ db.close();
48
+ dbPromise = null;
49
+
50
+ // Delete and recreate the database
51
+ const deleteRequest = indexedDB.deleteDatabase(DB_NAME);
52
+ deleteRequest.onsuccess = () => {
53
+ // Retry opening after deletion
54
+ openDatabase().then(resolve).catch(reject);
55
+ };
56
+ deleteRequest.onerror = () => {
57
+ reject(new Error('Failed to recreate database'));
58
+ };
59
+ return;
60
+ }
61
+
62
+ resolve(db);
63
+ };
64
+
65
+ request.onupgradeneeded = (event) => {
66
+ const db = (event.target as IDBOpenDBRequest).result;
67
+
68
+ // Create object store for cached models
69
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
70
+ const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' });
71
+ store.createIndex('createdAt', 'createdAt', { unique: false });
72
+ store.createIndex('fileName', 'fileName', { unique: false });
73
+ }
74
+ };
75
+ });
76
+
77
+ return dbPromise;
78
+ }
79
+
80
+ /**
81
+ * Get a cached model by hash key
82
+ */
83
+ export async function getCached(key: string): Promise<ArrayBuffer | null> {
84
+ try {
85
+ const db = await openDatabase();
86
+ return new Promise((resolve, reject) => {
87
+ const tx = db.transaction(STORE_NAME, 'readonly');
88
+ const store = tx.objectStore(STORE_NAME);
89
+ const request = store.get(key);
90
+
91
+ request.onsuccess = () => {
92
+ const entry = request.result as CacheEntry | undefined;
93
+ if (entry) {
94
+ console.log(`[IFC Cache] Cache hit for ${entry.fileName} (${(entry.fileSize / 1024 / 1024).toFixed(2)}MB)`);
95
+ resolve(entry.buffer);
96
+ } else {
97
+ resolve(null);
98
+ }
99
+ };
100
+
101
+ request.onerror = () => {
102
+ console.error('[IFC Cache] Failed to get cache entry:', request.error);
103
+ reject(request.error);
104
+ };
105
+ });
106
+ } catch (err) {
107
+ console.warn('[IFC Cache] Cache read failed:', err);
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Store a model in the cache
114
+ */
115
+ export async function setCached(
116
+ key: string,
117
+ buffer: ArrayBuffer,
118
+ fileName: string,
119
+ fileSize: number
120
+ ): Promise<void> {
121
+ try {
122
+ const db = await openDatabase();
123
+ return new Promise((resolve, reject) => {
124
+ const tx = db.transaction(STORE_NAME, 'readwrite');
125
+ const store = tx.objectStore(STORE_NAME);
126
+
127
+ const entry: CacheEntry = {
128
+ key,
129
+ buffer,
130
+ fileName,
131
+ fileSize,
132
+ createdAt: Date.now(),
133
+ };
134
+
135
+ const request = store.put(entry);
136
+
137
+ request.onsuccess = () => {
138
+ console.log(`[IFC Cache] Cached ${fileName} (${(fileSize / 1024 / 1024).toFixed(2)}MB)`);
139
+ resolve();
140
+ };
141
+
142
+ request.onerror = () => {
143
+ console.error('[IFC Cache] Failed to cache entry:', request.error);
144
+ reject(request.error);
145
+ };
146
+ });
147
+ } catch (err) {
148
+ console.warn('[IFC Cache] Cache write failed:', err);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Check if a cache entry exists
154
+ */
155
+ export async function hasCached(key: string): Promise<boolean> {
156
+ try {
157
+ const db = await openDatabase();
158
+ return new Promise((resolve, reject) => {
159
+ const tx = db.transaction(STORE_NAME, 'readonly');
160
+ const store = tx.objectStore(STORE_NAME);
161
+ const request = store.count(IDBKeyRange.only(key));
162
+
163
+ request.onsuccess = () => {
164
+ resolve(request.result > 0);
165
+ };
166
+
167
+ request.onerror = () => {
168
+ reject(request.error);
169
+ };
170
+ });
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Delete a cache entry
178
+ */
179
+ export async function deleteCached(key: string): Promise<void> {
180
+ try {
181
+ const db = await openDatabase();
182
+ return new Promise((resolve, reject) => {
183
+ const tx = db.transaction(STORE_NAME, 'readwrite');
184
+ const store = tx.objectStore(STORE_NAME);
185
+ const request = store.delete(key);
186
+
187
+ request.onsuccess = () => resolve();
188
+ request.onerror = () => reject(request.error);
189
+ });
190
+ } catch (err) {
191
+ console.warn('[IFC Cache] Failed to delete cache entry:', err);
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Clear all cached models
197
+ */
198
+ export async function clearCache(): Promise<void> {
199
+ try {
200
+ const db = await openDatabase();
201
+ return new Promise((resolve, reject) => {
202
+ const tx = db.transaction(STORE_NAME, 'readwrite');
203
+ const store = tx.objectStore(STORE_NAME);
204
+ const request = store.clear();
205
+
206
+ request.onsuccess = () => {
207
+ console.log('[IFC Cache] Cache cleared');
208
+ resolve();
209
+ };
210
+
211
+ request.onerror = () => reject(request.error);
212
+ });
213
+ } catch (err) {
214
+ console.warn('[IFC Cache] Failed to clear cache:', err);
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Get cache statistics
220
+ */
221
+ export async function getCacheStats(): Promise<{
222
+ entryCount: number;
223
+ totalSize: number;
224
+ entries: Array<{ fileName: string; fileSize: number; createdAt: Date }>;
225
+ }> {
226
+ try {
227
+ const db = await openDatabase();
228
+ return new Promise((resolve, reject) => {
229
+ const tx = db.transaction(STORE_NAME, 'readonly');
230
+ const store = tx.objectStore(STORE_NAME);
231
+ const request = store.getAll();
232
+
233
+ request.onsuccess = () => {
234
+ const entries = request.result as CacheEntry[];
235
+ resolve({
236
+ entryCount: entries.length,
237
+ totalSize: entries.reduce((sum, e) => sum + e.buffer.byteLength, 0),
238
+ entries: entries.map((e) => ({
239
+ fileName: e.fileName,
240
+ fileSize: e.fileSize,
241
+ createdAt: new Date(e.createdAt),
242
+ })),
243
+ });
244
+ };
245
+
246
+ request.onerror = () => reject(request.error);
247
+ });
248
+ } catch {
249
+ return { entryCount: 0, totalSize: 0, entries: [] };
250
+ }
251
+ }
package/src/store.ts CHANGED
@@ -307,13 +307,25 @@ export const useViewerStore = create<ViewerState>((set, get) => ({
307
307
 
308
308
  // Visibility actions
309
309
  hideEntity: (id) => set((state) => {
310
+ // Toggle hide: if already hidden, show it; otherwise hide it
310
311
  const newHidden = new Set(state.hiddenEntities);
311
- newHidden.add(id);
312
+ if (newHidden.has(id)) {
313
+ newHidden.delete(id);
314
+ } else {
315
+ newHidden.add(id);
316
+ }
312
317
  return { hiddenEntities: newHidden };
313
318
  }),
314
319
  hideEntities: (ids) => set((state) => {
320
+ // Toggle hide for each entity: if already hidden, show it; otherwise hide it
315
321
  const newHidden = new Set(state.hiddenEntities);
316
- ids.forEach(id => newHidden.add(id));
322
+ ids.forEach(id => {
323
+ if (newHidden.has(id)) {
324
+ newHidden.delete(id);
325
+ } else {
326
+ newHidden.add(id);
327
+ }
328
+ });
317
329
  return { hiddenEntities: newHidden };
318
330
  }),
319
331
  showEntity: (id) => set((state) => {
@@ -335,8 +347,47 @@ export const useViewerStore = create<ViewerState>((set, get) => ({
335
347
  }
336
348
  return { hiddenEntities: newHidden };
337
349
  }),
338
- isolateEntity: (id) => set({ isolatedEntities: new Set([id]) }),
339
- isolateEntities: (ids) => set({ isolatedEntities: new Set(ids) }),
350
+ isolateEntity: (id) => set((state) => {
351
+ // Toggle isolate: if this entity is already the only isolated one, clear isolation
352
+ // Otherwise, isolate it (and unhide it for good UX)
353
+ const isAlreadyIsolated = state.isolatedEntities !== null &&
354
+ state.isolatedEntities.size === 1 &&
355
+ state.isolatedEntities.has(id);
356
+
357
+ if (isAlreadyIsolated) {
358
+ // Toggle off: clear isolation
359
+ return { isolatedEntities: null };
360
+ } else {
361
+ // Toggle on: isolate this entity (and unhide it)
362
+ const newHidden = new Set(state.hiddenEntities);
363
+ newHidden.delete(id);
364
+ return {
365
+ isolatedEntities: new Set([id]),
366
+ hiddenEntities: newHidden,
367
+ };
368
+ }
369
+ }),
370
+ isolateEntities: (ids) => set((state) => {
371
+ // Toggle isolate: if these exact entities are already isolated, clear isolation
372
+ // Otherwise, isolate them (and unhide them for good UX)
373
+ const idsSet = new Set(ids);
374
+ const isAlreadyIsolated = state.isolatedEntities !== null &&
375
+ state.isolatedEntities.size === idsSet.size &&
376
+ ids.every(id => state.isolatedEntities!.has(id));
377
+
378
+ if (isAlreadyIsolated) {
379
+ // Toggle off: clear isolation
380
+ return { isolatedEntities: null };
381
+ } else {
382
+ // Toggle on: isolate these entities (and unhide them)
383
+ const newHidden = new Set(state.hiddenEntities);
384
+ ids.forEach(id => newHidden.delete(id));
385
+ return {
386
+ isolatedEntities: idsSet,
387
+ hiddenEntities: newHidden,
388
+ };
389
+ }
390
+ }),
340
391
  clearIsolation: () => set({ isolatedEntities: null }),
341
392
  showAll: () => set({ hiddenEntities: new Set(), isolatedEntities: null }),
342
393
  isEntityVisible: (id) => {
package/tsconfig.json CHANGED
@@ -9,7 +9,11 @@
9
9
  "@ifc-lite/parser": ["../../packages/parser/src"],
10
10
  "@ifc-lite/geometry": ["../../packages/geometry/src"],
11
11
  "@ifc-lite/renderer": ["../../packages/renderer/src"],
12
- "@ifc-lite/query": ["../../packages/query/src"]
12
+ "@ifc-lite/query": ["../../packages/query/src"],
13
+ "@ifc-lite/spatial": ["../../packages/spatial/src"],
14
+ "@ifc-lite/data": ["../../packages/data/src"],
15
+ "@ifc-lite/export": ["../../packages/export/src"],
16
+ "@ifc-lite/cache": ["../../packages/cache/src"]
13
17
  }
14
18
  },
15
19
  "include": ["src/**/*", "../../packages/renderer/src/webgpu-types.d.ts"]