@ifc-lite/viewer 1.15.0 → 1.17.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 (62) hide show
  1. package/.turbo/turbo-build.log +46 -0
  2. package/.turbo/turbo-typecheck.log +4 -0
  3. package/CHANGELOG.md +35 -0
  4. package/dist/assets/{Arrow.dom-OVBBPqOB.js → Arrow.dom-CcoDLP6E.js} +1 -1
  5. package/dist/assets/{basketViewActivator-Bx6QU4ma.js → basketViewActivator-FtbS__bG.js} +1 -1
  6. package/dist/assets/{browser-BMqEoJw4.js → browser-CXd3z0DO.js} +1 -1
  7. package/dist/assets/ifc-lite-TI3u_Zyw.js +7 -0
  8. package/dist/assets/ifc-lite_bg-DeZrXTKQ.wasm +0 -0
  9. package/dist/assets/index-Ba4eoTe7.css +1 -0
  10. package/dist/assets/{index-DZY6uD8A.js → index-D99fzcwI.js} +32109 -28671
  11. package/dist/assets/{index-DsX-NCtx.js → index-DqNiuQep.js} +4 -4
  12. package/dist/assets/{native-bridge-D6tKFqGO.js → native-bridge-DjDj2M6p.js} +1 -1
  13. package/dist/assets/{wasm-bridge-D4kvZVDw.js → wasm-bridge-CDTF4ZQc.js} +1 -1
  14. package/dist/assets/workerHelpers-G7llXNMi.js +36 -0
  15. package/dist/index.html +7 -2
  16. package/index.html +5 -0
  17. package/package.json +15 -14
  18. package/src/components/viewer/BCFPanel.tsx +12 -0
  19. package/src/components/viewer/BulkPropertyEditor.tsx +315 -154
  20. package/src/components/viewer/CommandPalette.tsx +0 -6
  21. package/src/components/viewer/DataConnector.tsx +489 -284
  22. package/src/components/viewer/ExportDialog.tsx +66 -6
  23. package/src/components/viewer/KeyboardShortcutsDialog.tsx +227 -82
  24. package/src/components/viewer/MainToolbar.tsx +1 -5
  25. package/src/components/viewer/Viewport.tsx +42 -56
  26. package/src/components/viewer/ViewportContainer.tsx +3 -0
  27. package/src/components/viewer/ViewportOverlays.tsx +12 -10
  28. package/src/components/viewer/bcf/BCFOverlay.tsx +254 -0
  29. package/src/components/viewer/hierarchy/HierarchyNode.tsx +26 -20
  30. package/src/components/viewer/hierarchy/ifc-icons.ts +90 -0
  31. package/src/components/viewer/lists/ListPanel.tsx +0 -21
  32. package/src/components/viewer/lists/ListResultsTable.tsx +93 -5
  33. package/src/components/viewer/measureHandlers.ts +558 -0
  34. package/src/components/viewer/mouseHandlerTypes.ts +108 -0
  35. package/src/components/viewer/selectionHandlers.ts +86 -0
  36. package/src/components/viewer/useAnimationLoop.ts +116 -44
  37. package/src/components/viewer/useGeometryStreaming.ts +155 -367
  38. package/src/components/viewer/useKeyboardControls.ts +30 -46
  39. package/src/components/viewer/useMouseControls.ts +169 -695
  40. package/src/components/viewer/useRenderUpdates.ts +9 -59
  41. package/src/components/viewer/useTouchControls.ts +55 -40
  42. package/src/hooks/bcfIdLookup.ts +70 -0
  43. package/src/hooks/useBCF.ts +12 -31
  44. package/src/hooks/useIfcCache.ts +11 -29
  45. package/src/hooks/useIfcFederation.ts +5 -11
  46. package/src/hooks/useIfcLoader.ts +47 -56
  47. package/src/hooks/useIfcServer.ts +9 -1
  48. package/src/hooks/useKeyboardShortcuts.ts +28 -12
  49. package/src/hooks/useLatestRef.ts +24 -0
  50. package/src/sdk/adapters/export-adapter.ts +2 -2
  51. package/src/sdk/adapters/model-adapter.ts +1 -0
  52. package/src/sdk/local-backend.ts +2 -0
  53. package/src/store/basketVisibleSet.ts +12 -0
  54. package/src/store/slices/bcfSlice.ts +9 -0
  55. package/src/store/slices/pinboardSlice.ts +46 -45
  56. package/src/utils/loadingUtils.ts +46 -0
  57. package/src/utils/serverDataModel.ts +4 -3
  58. package/src/utils/spatialHierarchy.ts +1 -1
  59. package/src/vite-env.d.ts +6 -2
  60. package/vite.config.ts +75 -23
  61. package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
  62. package/dist/assets/index-CJr7Itua.css +0 -1
@@ -7,7 +7,7 @@
7
7
  * Full integration with BulkQueryEngine
8
8
  */
9
9
 
10
- import { useState, useCallback, useMemo, useEffect } from 'react';
10
+ import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
11
11
  import {
12
12
  Search,
13
13
  Play,
@@ -47,6 +47,7 @@ import {
47
47
  AlertDescription,
48
48
  AlertTitle,
49
49
  } from '@/components/ui/alert';
50
+ import { Progress } from '@/components/ui/progress';
50
51
  import { Separator } from '@/components/ui/separator';
51
52
  import { useViewerStore } from '@/store';
52
53
  import { useIfc } from '@/hooks/useIfc';
@@ -143,11 +144,20 @@ export function BulkPropertyEditor({ trigger }: BulkPropertyEditorProps) {
143
144
 
144
145
  // Execution state
145
146
  const [isExecuting, setIsExecuting] = useState(false);
147
+ const [executeProgress, setExecuteProgress] = useState<{ done: number; total: number } | null>(null);
148
+ const executeCancelRef = useRef(false);
149
+ const scrollAreaRef = useRef<HTMLDivElement>(null);
146
150
  const [previewResult, setPreviewResult] = useState<BulkQueryPreview | null>(null);
147
151
  const [executeResult, setExecuteResult] = useState<BulkQueryResult | null>(null);
152
+ // Track whether config changed since last execute (disables button after success)
153
+ const [executeDirty, setExecuteDirty] = useState(true);
154
+ const prevProgressRef = useRef<{ done: number; total: number } | null>(null);
148
155
 
149
- // Get list of models - includes both federated models and legacy single-model
156
+ // --- All expensive computation is gated behind `open` so IFC loading is never impacted ---
157
+
158
+ // Get list of models - only when dialog is open
150
159
  const modelList = useMemo(() => {
160
+ if (!open) return [];
151
161
  const list = Array.from(models.values()).map((m) => ({
152
162
  id: m.id,
153
163
  name: m.name,
@@ -162,17 +172,18 @@ export function BulkPropertyEditor({ trigger }: BulkPropertyEditorProps) {
162
172
  }
163
173
 
164
174
  return list;
165
- }, [models, legacyIfcDataStore]);
175
+ }, [open, models, legacyIfcDataStore]);
166
176
 
167
- // Auto-select first model
168
- useMemo(() => {
169
- if (modelList.length > 0 && !selectedModelId) {
177
+ // Auto-select first model when dialog opens
178
+ useEffect(() => {
179
+ if (open && modelList.length > 0 && !selectedModelId) {
170
180
  setSelectedModelId(modelList[0].id);
171
181
  }
172
- }, [modelList, selectedModelId]);
182
+ }, [open, modelList, selectedModelId]);
173
183
 
174
184
  // Get selected model's data - supports both federated and legacy mode
175
185
  const selectedModel = useMemo(() => {
186
+ if (!open) return undefined;
176
187
  if (selectedModelId === '__legacy__' && legacyIfcDataStore && legacyGeometryResult) {
177
188
  // Return a synthetic FederatedModel-like object for legacy mode
178
189
  return {
@@ -185,51 +196,91 @@ export function BulkPropertyEditor({ trigger }: BulkPropertyEditorProps) {
185
196
  };
186
197
  }
187
198
  return models.get(selectedModelId);
188
- }, [models, selectedModelId, legacyIfcDataStore, legacyGeometryResult]);
189
-
190
- // Get storeys from selected model
191
- const availableStoreys = useMemo(() => {
192
- if (!selectedModel?.ifcDataStore?.spatialHierarchy) return [];
193
- const storeys: { id: number; name: string; elevation?: number }[] = [];
194
- const hierarchy = selectedModel.ifcDataStore.spatialHierarchy;
195
-
196
- for (const [storeyId] of hierarchy.byStorey) {
197
- const name = selectedModel.ifcDataStore.entities.getName(storeyId) || `Storey #${storeyId}`;
198
- const elevation = hierarchy.storeyElevations.get(storeyId);
199
- storeys.push({ id: storeyId, name, elevation });
199
+ }, [open, models, selectedModelId, legacyIfcDataStore, legacyGeometryResult]);
200
+
201
+ // Loading state for initial dialog open computation
202
+ const [isInitializing, setIsInitializing] = useState(false);
203
+
204
+ // Get storeys, available types, and typeEnum mapping computed once on dialog open,
205
+ // deferred via setTimeout so the dialog shell renders instantly with a spinner.
206
+ const [availableStoreys, setAvailableStoreys] = useState<{ id: number; name: string; elevation?: number }[]>([]);
207
+ const [availableTypes, setAvailableTypes] = useState<{ ifcType: string; label: string }[]>([]);
208
+ const typeNameToEnumsRef = useRef<Map<string, number[]>>(new Map());
209
+ const [typeNameToEnums, setTypeNameToEnums] = useState<Map<string, number[]>>(new Map());
210
+ const initTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
211
+
212
+ useEffect(() => {
213
+ if (initTimerRef.current) clearTimeout(initTimerRef.current);
214
+
215
+ if (!open || !selectedModel?.ifcDataStore) {
216
+ setAvailableStoreys([]);
217
+ setAvailableTypes([]);
218
+ typeNameToEnumsRef.current = new Map();
219
+ setTypeNameToEnums(new Map());
220
+ return;
200
221
  }
201
222
 
202
- // Sort by elevation (highest first)
203
- storeys.sort((a, b) => (b.elevation ?? 0) - (a.elevation ?? 0));
204
- return storeys;
205
- }, [selectedModel]);
206
-
207
- // Get available entity types from the model
208
- const availableTypes = useMemo(() => {
209
- if (!selectedModel?.ifcDataStore) return [];
210
- const entities = selectedModel.ifcDataStore.entities;
211
- const typeSet = new Set<string>();
212
-
213
- for (let i = 0; i < entities.count; i++) {
214
- const expressId = entities.expressId[i];
215
- const typeName = entities.getTypeName(expressId);
216
- if (typeName) {
217
- typeSet.add(typeName);
223
+ setIsInitializing(true);
224
+
225
+ // Yield to browser so dialog shell + spinner paint first
226
+ initTimerRef.current = setTimeout(() => {
227
+ const dataStore = selectedModel.ifcDataStore;
228
+ const entities = dataStore.entities;
229
+
230
+ // Storeys
231
+ const storeys: { id: number; name: string; elevation?: number }[] = [];
232
+ if (dataStore.spatialHierarchy) {
233
+ const hierarchy = dataStore.spatialHierarchy;
234
+ for (const [storeyId] of hierarchy.byStorey) {
235
+ const name = entities.getName(storeyId) || `Storey #${storeyId}`;
236
+ const elevation = hierarchy.storeyElevations.get(storeyId);
237
+ storeys.push({ id: storeyId, name, elevation });
238
+ }
239
+ storeys.sort((a, b) => (b.elevation ?? 0) - (a.elevation ?? 0));
240
+ }
241
+
242
+ // Type enum mapping — single pass
243
+ const enumToTypeName = new Map<number, string>();
244
+ for (let i = 0; i < entities.count; i++) {
245
+ const typeEnum = entities.typeEnum[i];
246
+ if (enumToTypeName.has(typeEnum)) continue;
247
+ const expressId = entities.expressId[i];
248
+ const typeName = entities.getTypeName(expressId);
249
+ if (typeName) {
250
+ enumToTypeName.set(typeEnum, typeName);
251
+ }
218
252
  }
219
- }
220
253
 
221
- // Map to our UI format, filtering to common types
222
- return Object.entries(IFC_TYPE_MAP)
223
- .filter(([ifcType]) => {
224
- const { pattern } = IFC_TYPE_MAP[ifcType];
225
- return Array.from(typeSet).some(t => t.includes(pattern));
226
- })
227
- .map(([ifcType, { label }]) => ({ ifcType, label }));
228
- }, [selectedModel]);
254
+ const nameToEnums = new Map<string, number[]>();
255
+ const presentTypes: { ifcType: string; label: string }[] = [];
256
+ for (const [ifcType, { label, pattern }] of Object.entries(IFC_TYPE_MAP)) {
257
+ const enums: number[] = [];
258
+ for (const [typeEnum, typeName] of enumToTypeName) {
259
+ if (typeName.includes(pattern)) {
260
+ enums.push(typeEnum);
261
+ }
262
+ }
263
+ if (enums.length > 0) {
264
+ nameToEnums.set(ifcType, enums);
265
+ presentTypes.push({ ifcType, label });
266
+ }
267
+ }
229
268
 
230
- // Ensure mutation view exists for selected model
269
+ setAvailableStoreys(storeys);
270
+ setAvailableTypes(presentTypes);
271
+ typeNameToEnumsRef.current = nameToEnums;
272
+ setTypeNameToEnums(nameToEnums);
273
+ setIsInitializing(false);
274
+ }, 0);
275
+
276
+ return () => {
277
+ if (initTimerRef.current) clearTimeout(initTimerRef.current);
278
+ };
279
+ }, [open, selectedModel]);
280
+
281
+ // Ensure mutation view exists for selected model — only when dialog is open
231
282
  useEffect(() => {
232
- if (!selectedModel?.ifcDataStore || !selectedModelId) return;
283
+ if (!open || !selectedModel?.ifcDataStore || !selectedModelId) return;
233
284
 
234
285
  // Check if mutation view already exists
235
286
  let mutationView = getMutationView(selectedModelId);
@@ -243,11 +294,11 @@ export function BulkPropertyEditor({ trigger }: BulkPropertyEditorProps) {
243
294
 
244
295
  // Register the mutation view
245
296
  registerMutationView(selectedModelId, mutationView);
246
- }, [selectedModel, selectedModelId, getMutationView, registerMutationView]);
297
+ }, [open, selectedModel, selectedModelId, getMutationView, registerMutationView]);
247
298
 
248
- // Create BulkQueryEngine instance - depend on mutationViews to re-render when view is registered
299
+ // Create BulkQueryEngine instance only when dialog is open
249
300
  const queryEngine = useMemo(() => {
250
- if (!selectedModel?.ifcDataStore) return null;
301
+ if (!open || !selectedModel?.ifcDataStore) return null;
251
302
  const mutationView = mutationViews.get(selectedModelId);
252
303
  if (!mutationView) return null;
253
304
 
@@ -259,37 +310,21 @@ export function BulkPropertyEditor({ trigger }: BulkPropertyEditorProps) {
259
310
  dataStore.properties || null,
260
311
  dataStore.strings || null
261
312
  );
262
- }, [selectedModel, selectedModelId, mutationViews]);
313
+ }, [open, selectedModel, selectedModelId, mutationViews]);
263
314
 
264
- // Build selection criteria for the query engine (memoized for live count)
315
+ // Build selection criteria using pre-computed typeEnum mapping (no entity scan needed)
265
316
  const currentCriteria = useMemo((): SelectionCriteria => {
266
317
  const criteria: SelectionCriteria = {};
267
318
 
268
- // Filter by entity types - need to find type enum IDs
269
- if (selectedTypes.length > 0 && selectedModel?.ifcDataStore) {
270
- const entities = selectedModel.ifcDataStore.entities;
319
+ // Use pre-computed typeNameToEnums map instead of scanning all entities
320
+ if (selectedTypes.length > 0) {
271
321
  const typeEnums: number[] = [];
272
-
273
- // Find type enum values that match our selected types
274
- const seenEnums = new Set<number>();
275
- for (let i = 0; i < entities.count; i++) {
276
- const typeEnum = entities.typeEnum[i];
277
- if (seenEnums.has(typeEnum)) continue;
278
- seenEnums.add(typeEnum);
279
-
280
- const expressId = entities.expressId[i];
281
- const typeName = entities.getTypeName(expressId);
282
- if (typeName) {
283
- for (const selectedType of selectedTypes) {
284
- const { pattern } = IFC_TYPE_MAP[selectedType] || { pattern: selectedType };
285
- if (typeName.includes(pattern)) {
286
- typeEnums.push(typeEnum);
287
- break;
288
- }
289
- }
322
+ for (const selectedType of selectedTypes) {
323
+ const enums = typeNameToEnums.get(selectedType);
324
+ if (enums) {
325
+ typeEnums.push(...enums);
290
326
  }
291
327
  }
292
-
293
328
  if (typeEnums.length > 0) {
294
329
  criteria.entityTypes = typeEnums;
295
330
  }
@@ -326,63 +361,98 @@ export function BulkPropertyEditor({ trigger }: BulkPropertyEditorProps) {
326
361
  }
327
362
 
328
363
  return criteria;
329
- }, [selectedTypes, selectedStoreys, namePattern, filters, selectedModel]);
364
+ }, [selectedTypes, selectedStoreys, namePattern, filters, typeNameToEnums]);
330
365
 
331
- // Live entity count based on current criteria
332
- const liveMatchCount = useMemo(() => {
333
- if (!queryEngine) return 0;
334
- try {
335
- const matchedIds = queryEngine.select(currentCriteria);
336
- return matchedIds.length;
337
- } catch {
338
- return 0;
339
- }
340
- }, [queryEngine, currentCriteria]);
366
+ // Deferred computation: all expensive work (select + property discovery) yields to the
367
+ // browser first so pill toggles paint instantly, then runs via setTimeout(0).
368
+ const [isComputing, setIsComputing] = useState(false);
369
+ const [matchResult, setMatchResult] = useState<{
370
+ count: number;
371
+ psets: Map<string, Set<string>>;
372
+ allProps: Set<string>;
373
+ }>({ count: 0, psets: new Map(), allProps: new Set() });
341
374
 
342
- // Discover available properties from matched entities (sample first 100 for performance)
343
- const discoveredProperties = useMemo(() => {
344
- if (!selectedModel?.ifcDataStore || !queryEngine) return { psets: new Map<string, Set<string>>(), allProps: new Set<string>() };
375
+ // Timers for deferred work
376
+ const selectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
377
+ const discoveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
345
378
 
346
- const psets = new Map<string, Set<string>>();
347
- const allProps = new Set<string>();
348
- const dataStore = selectedModel.ifcDataStore;
379
+ useEffect(() => {
380
+ // Cancel any in-flight work
381
+ if (selectTimerRef.current) clearTimeout(selectTimerRef.current);
382
+ if (discoveryTimerRef.current) clearTimeout(discoveryTimerRef.current);
383
+
384
+ if (!queryEngine) {
385
+ setIsComputing(false);
386
+ setMatchResult({ count: 0, psets: new Map(), allProps: new Set() });
387
+ return;
388
+ }
349
389
 
350
- try {
351
- // Get matching entity IDs
352
- let entityIds = queryEngine.select(currentCriteria);
353
- // Sample first 100 entities for performance
354
- if (entityIds.length > 100) {
355
- entityIds = entityIds.slice(0, 100);
390
+ // Show spinner immediately — before any expensive work
391
+ setIsComputing(true);
392
+
393
+ // Yield to browser so the pill toggle paints, then run select()
394
+ selectTimerRef.current = setTimeout(() => {
395
+ let count = 0;
396
+ let matchedIds: number[] = [];
397
+ try {
398
+ matchedIds = queryEngine.select(currentCriteria);
399
+ count = matchedIds.length;
400
+ } catch {
401
+ // leave at 0
356
402
  }
357
403
 
358
- // Extract properties from each entity
359
- for (const entityId of entityIds) {
360
- let properties: Array<{ name: string; properties: Array<{ name: string }> }> = [];
361
-
362
- // Use on-demand extraction if available
363
- if (dataStore.onDemandPropertyMap && dataStore.source?.length > 0) {
364
- properties = extractPropertiesOnDemand(dataStore as IfcDataStore, entityId);
365
- } else if (dataStore.properties) {
366
- properties = dataStore.properties.getForEntity(entityId);
367
- }
368
-
369
- for (const pset of properties) {
370
- if (!psets.has(pset.name)) {
371
- psets.set(pset.name, new Set());
372
- }
373
- const propSet = psets.get(pset.name)!;
374
- for (const prop of pset.properties) {
375
- propSet.add(prop.name);
376
- allProps.add(prop.name);
404
+ // Update count right away, keep old psets until discovery finishes
405
+ setMatchResult(prev => ({ ...prev, count }));
406
+
407
+ // Debounce property discovery (most expensive) by another 200ms
408
+ const capturedIds = matchedIds;
409
+ discoveryTimerRef.current = setTimeout(() => {
410
+ const psets = new Map<string, Set<string>>();
411
+ const allProps = new Set<string>();
412
+
413
+ if (selectedModel?.ifcDataStore && capturedIds.length > 0) {
414
+ const dataStore = selectedModel.ifcDataStore;
415
+ const sampleIds = capturedIds.length > 100 ? capturedIds.slice(0, 100) : capturedIds;
416
+
417
+ try {
418
+ for (const entityId of sampleIds) {
419
+ let properties: Array<{ name: string; properties: Array<{ name: string }> }> = [];
420
+
421
+ if (dataStore.onDemandPropertyMap && dataStore.source?.length > 0) {
422
+ properties = extractPropertiesOnDemand(dataStore as IfcDataStore, entityId);
423
+ } else if (dataStore.properties) {
424
+ properties = dataStore.properties.getForEntity(entityId);
425
+ }
426
+
427
+ for (const pset of properties) {
428
+ if (!psets.has(pset.name)) {
429
+ psets.set(pset.name, new Set());
430
+ }
431
+ const propSet = psets.get(pset.name)!;
432
+ for (const prop of pset.properties) {
433
+ propSet.add(prop.name);
434
+ allProps.add(prop.name);
435
+ }
436
+ }
437
+ }
438
+ } catch (e) {
439
+ console.error('Error discovering properties:', e);
377
440
  }
378
441
  }
379
- }
380
- } catch (e) {
381
- console.error('Error discovering properties:', e);
382
- }
383
442
 
384
- return { psets, allProps };
385
- }, [selectedModel, queryEngine, currentCriteria]);
443
+ setMatchResult({ count, psets, allProps });
444
+ setIsComputing(false);
445
+ }, 200);
446
+ }, 0);
447
+
448
+ return () => {
449
+ if (selectTimerRef.current) clearTimeout(selectTimerRef.current);
450
+ if (discoveryTimerRef.current) clearTimeout(discoveryTimerRef.current);
451
+ };
452
+ }, [queryEngine, currentCriteria, selectedModel]);
453
+
454
+ const liveMatchCount = matchResult.count;
455
+ const discoveredProperties = { psets: matchResult.psets, allProps: matchResult.allProps };
386
456
 
387
457
  // Flatten discovered properties for selectors
388
458
  const psetOptions = useMemo(() => {
@@ -473,24 +543,59 @@ export function BulkPropertyEditor({ trigger }: BulkPropertyEditorProps) {
473
543
  }
474
544
  }, [queryEngine, currentCriteria, buildAction]);
475
545
 
476
- // Execute bulk update
546
+ // Execute bulk update — chunked so the UI stays responsive with a live progress bar
477
547
  const handleExecute = useCallback(async () => {
478
548
  if (!queryEngine || liveMatchCount === 0) return;
479
549
 
480
550
  setIsExecuting(true);
481
551
  setExecuteResult(null);
552
+ setExecuteProgress({ done: 0, total: 0 });
553
+ executeCancelRef.current = false;
554
+
555
+ // Yield to paint the initial "Applying..." state
556
+ await new Promise(r => setTimeout(r, 0));
482
557
 
483
558
  try {
484
559
  const action = buildAction();
485
- console.log('[BulkPropertyEditor] Executing action:', action, 'on', liveMatchCount, 'entities');
486
- const result = queryEngine.execute({ select: currentCriteria, action });
487
- console.log('[BulkPropertyEditor] Execute result:', result);
560
+
561
+ // Step 1: select matching IDs
562
+ const entityIds = queryEngine.select(currentCriteria);
563
+ const total = entityIds.length;
564
+ setExecuteProgress({ done: 0, total });
565
+
566
+ // Step 2: chunked mutation — process CHUNK_SIZE entities then yield to browser
567
+ const CHUNK_SIZE = 500;
568
+ const mutations: import('@ifc-lite/mutations').BulkQueryResult['mutations'] = [];
569
+ const errors: string[] = [];
570
+
571
+ for (let i = 0; i < total; i += CHUNK_SIZE) {
572
+ if (executeCancelRef.current) break;
573
+
574
+ const end = Math.min(i + CHUNK_SIZE, total);
575
+ for (let j = i; j < end; j++) {
576
+ try {
577
+ const mutation = queryEngine.applyAction(entityIds[j], action);
578
+ if (mutation) mutations.push(mutation);
579
+ } catch (error) {
580
+ errors.push(`Entity ${entityIds[j]}: ${error instanceof Error ? error.message : 'Unknown error'}`);
581
+ }
582
+ }
583
+
584
+ setExecuteProgress({ done: end, total });
585
+ // Yield to browser so progress bar and spinner update
586
+ await new Promise(r => setTimeout(r, 0));
587
+ }
588
+
589
+ const result: BulkQueryResult = {
590
+ mutations,
591
+ affectedEntityCount: mutations.length,
592
+ success: errors.length === 0 && !executeCancelRef.current,
593
+ errors: errors.length > 0 ? errors : undefined,
594
+ };
488
595
  setExecuteResult(result);
596
+ if (result.success) setExecuteDirty(false);
489
597
 
490
- // Bump mutation version to trigger re-renders in PropertiesPanel
491
- // (BulkQueryEngine applies mutations directly to MutablePropertyView, bypassing store)
492
598
  if (result.mutations.length > 0) {
493
- console.log('[BulkPropertyEditor] Bumping mutation version after', result.mutations.length, 'mutations');
494
599
  bumpMutationVersion();
495
600
  }
496
601
  } catch (error) {
@@ -503,6 +608,7 @@ export function BulkPropertyEditor({ trigger }: BulkPropertyEditorProps) {
503
608
  });
504
609
  } finally {
505
610
  setIsExecuting(false);
611
+ setExecuteProgress(null);
506
612
  }
507
613
  }, [queryEngine, liveMatchCount, currentCriteria, buildAction, bumpMutationVersion]);
508
614
 
@@ -517,8 +623,38 @@ export function BulkPropertyEditor({ trigger }: BulkPropertyEditorProps) {
517
623
  setTargetValue('');
518
624
  setPreviewResult(null);
519
625
  setExecuteResult(null);
626
+ setExecuteDirty(true);
627
+ }, []);
628
+
629
+ // Scroll to bottom — double rAF ensures DOM is painted
630
+ const scrollToBottom = useCallback(() => {
631
+ requestAnimationFrame(() => {
632
+ requestAnimationFrame(() => {
633
+ scrollAreaRef.current?.scrollTo({
634
+ top: scrollAreaRef.current.scrollHeight,
635
+ behavior: 'smooth',
636
+ });
637
+ });
638
+ });
520
639
  }, []);
521
640
 
641
+ // Auto-scroll when execute completes
642
+ useEffect(() => {
643
+ if (executeResult) scrollToBottom();
644
+ }, [executeResult, scrollToBottom]);
645
+
646
+ // Auto-scroll when progress first appears
647
+ useEffect(() => {
648
+ if (executeProgress && !prevProgressRef.current) scrollToBottom();
649
+ prevProgressRef.current = executeProgress;
650
+ }, [executeProgress, scrollToBottom]);
651
+
652
+ // Mark config dirty when criteria or action settings change after a completed execute
653
+ useEffect(() => {
654
+ if (executeResult) setExecuteDirty(true);
655
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- only fire on config changes
656
+ }, [selectedTypes, selectedStoreys, namePattern, filters, actionType, targetPset, targetProp, targetValue, valueType]);
657
+
522
658
  return (
523
659
  <Dialog open={open} onOpenChange={setOpen}>
524
660
  <DialogTrigger asChild>
@@ -529,8 +665,8 @@ export function BulkPropertyEditor({ trigger }: BulkPropertyEditorProps) {
529
665
  </Button>
530
666
  )}
531
667
  </DialogTrigger>
532
- <DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
533
- <DialogHeader>
668
+ <DialogContent className="sm:max-w-2xl max-h-[85vh] flex flex-col p-0 gap-0">
669
+ <DialogHeader className="px-6 pt-6 pb-4 shrink-0 border-b">
534
670
  <DialogTitle className="flex items-center gap-2">
535
671
  <Filter className="h-5 w-5" />
536
672
  Bulk Property Editor
@@ -540,7 +676,14 @@ export function BulkPropertyEditor({ trigger }: BulkPropertyEditorProps) {
540
676
  </DialogDescription>
541
677
  </DialogHeader>
542
678
 
543
- <div className="space-y-6 py-4">
679
+ <div ref={scrollAreaRef} className="flex-1 overflow-y-auto px-6 py-4">
680
+ {isInitializing ? (
681
+ <div className="flex items-center justify-center py-12 gap-2 text-muted-foreground">
682
+ <Loader2 className="h-5 w-5 animate-spin" />
683
+ <span className="text-sm">Loading model data...</span>
684
+ </div>
685
+ ) : (
686
+ <div className="space-y-6">
544
687
  {/* Model selector */}
545
688
  <div className="space-y-2">
546
689
  <Label className="text-sm font-medium">Model</Label>
@@ -566,6 +709,7 @@ export function BulkPropertyEditor({ trigger }: BulkPropertyEditorProps) {
566
709
  Selection Criteria
567
710
  </Label>
568
711
  <Badge variant={liveMatchCount > 0 ? 'default' : 'secondary'} className="text-xs">
712
+ {isComputing && <Loader2 className="h-3 w-3 mr-1 animate-spin" />}
569
713
  {liveMatchCount} {liveMatchCount === 1 ? 'entity' : 'entities'} matched
570
714
  </Badge>
571
715
  </div>
@@ -826,6 +970,22 @@ export function BulkPropertyEditor({ trigger }: BulkPropertyEditorProps) {
826
970
  </Alert>
827
971
  )}
828
972
 
973
+ {/* Execute Progress */}
974
+ {isExecuting && executeProgress && (
975
+ <div className="space-y-2">
976
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
977
+ <span className="flex items-center gap-2">
978
+ <Loader2 className="h-4 w-4 animate-spin" />
979
+ Applying changes...
980
+ </span>
981
+ <span>
982
+ {executeProgress.done.toLocaleString()} / {executeProgress.total.toLocaleString()} entities
983
+ </span>
984
+ </div>
985
+ <Progress value={executeProgress.total > 0 ? (executeProgress.done / executeProgress.total) * 100 : 0} />
986
+ </div>
987
+ )}
988
+
829
989
  {/* Execute Result */}
830
990
  {executeResult && (
831
991
  <Alert variant={executeResult.success ? 'default' : 'destructive'}>
@@ -833,37 +993,38 @@ export function BulkPropertyEditor({ trigger }: BulkPropertyEditorProps) {
833
993
  <AlertTitle>{executeResult.success ? 'Success' : 'Error'}</AlertTitle>
834
994
  <AlertDescription>
835
995
  {executeResult.success
836
- ? `Applied ${executeResult.mutations.length} mutations to ${executeResult.affectedEntityCount} entities`
996
+ ? `Applied ${executeResult.mutations.length.toLocaleString()} mutations to ${executeResult.affectedEntityCount.toLocaleString()} entities`
837
997
  : executeResult.errors?.join(', ') || 'Unknown error'}
838
998
  </AlertDescription>
839
999
  </Alert>
840
1000
  )}
841
1001
  </div>
1002
+ )}
1003
+ </div>
842
1004
 
843
- <DialogFooter className="gap-2">
844
- <Button variant="outline" onClick={handleReset}>
845
- Reset
846
- </Button>
847
- <Button variant="secondary" onClick={handlePreview} disabled={!queryEngine}>
848
- <Eye className="h-4 w-4 mr-2" />
849
- Preview
850
- </Button>
851
- <Button
852
- onClick={handleExecute}
853
- disabled={liveMatchCount === 0 || !targetProp || (actionType !== 'SET_ATTRIBUTE' && !targetPset) || isExecuting}
854
- >
855
- {isExecuting ? (
856
- <>
857
- <Loader2 className="h-4 w-4 mr-2 animate-spin" />
858
- Applying...
859
- </>
860
- ) : (
861
- <>
1005
+ <DialogFooter className="px-6 py-4 border-t shrink-0 gap-2">
1006
+ {isExecuting ? (
1007
+ <Button variant="destructive" onClick={() => { executeCancelRef.current = true; }}>
1008
+ Cancel
1009
+ </Button>
1010
+ ) : (
1011
+ <>
1012
+ <Button variant="outline" onClick={handleReset}>
1013
+ Reset
1014
+ </Button>
1015
+ <Button variant="secondary" onClick={handlePreview} disabled={!queryEngine}>
1016
+ <Eye className="h-4 w-4 mr-2" />
1017
+ Preview
1018
+ </Button>
1019
+ <Button
1020
+ onClick={handleExecute}
1021
+ disabled={liveMatchCount === 0 || !targetProp || (actionType !== 'SET_ATTRIBUTE' && !targetPset) || !executeDirty}
1022
+ >
862
1023
  <Play className="h-4 w-4 mr-2" />
863
1024
  Apply to {liveMatchCount} entities
864
- </>
865
- )}
866
- </Button>
1025
+ </Button>
1026
+ </>
1027
+ )}
867
1028
  </DialogFooter>
868
1029
  </DialogContent>
869
1030
  </Dialog>
@@ -18,8 +18,6 @@ import {
18
18
  Search,
19
19
  Play,
20
20
  MousePointer2,
21
- Hand,
22
- Rotate3d,
23
21
  PersonStanding,
24
22
  Ruler,
25
23
  Scissors,
@@ -313,10 +311,6 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
313
311
  c.push(
314
312
  { id: 'tool:select', label: 'Select', keywords: 'pick click pointer', category: 'Tools', icon: MousePointer2, shortcut: 'V',
315
313
  action: () => { useViewerStore.getState().setActiveTool('select'); } },
316
- { id: 'tool:pan', label: 'Pan', keywords: 'move drag hand', category: 'Tools', icon: Hand, shortcut: 'P',
317
- action: () => { useViewerStore.getState().setActiveTool('pan'); } },
318
- { id: 'tool:orbit', label: 'Orbit', keywords: 'rotate spin', category: 'Tools', icon: Rotate3d, shortcut: 'O',
319
- action: () => { useViewerStore.getState().setActiveTool('orbit'); } },
320
314
  { id: 'tool:walk', label: 'Walk', keywords: 'first person navigate wasd', category: 'Tools', icon: PersonStanding, shortcut: 'C',
321
315
  action: () => { useViewerStore.getState().setActiveTool('walk'); } },
322
316
  { id: 'tool:measure', label: 'Measure', keywords: 'distance ruler dimension', category: 'Tools', icon: Ruler, shortcut: 'M',