@ifc-lite/viewer 1.16.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.
- package/.turbo/turbo-build.log +46 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +15 -0
- package/dist/assets/{Arrow.dom--gdrQd-q.js → Arrow.dom-CcoDLP6E.js} +1 -1
- package/dist/assets/{basketViewActivator-CI3y6VYQ.js → basketViewActivator-FtbS__bG.js} +1 -1
- package/dist/assets/{browser-vWDubxDI.js → browser-CXd3z0DO.js} +1 -1
- package/dist/assets/ifc-lite-TI3u_Zyw.js +7 -0
- package/dist/assets/ifc-lite_bg-DeZrXTKQ.wasm +0 -0
- package/dist/assets/index-Ba4eoTe7.css +1 -0
- package/dist/assets/{index-BImINgzG.js → index-D99fzcwI.js} +28962 -26947
- package/dist/assets/{index-RXIK18da.js → index-DqNiuQep.js} +4 -4
- package/dist/assets/{native-bridge-4rLidc3f.js → native-bridge-DjDj2M6p.js} +1 -1
- package/dist/assets/{wasm-bridge-BkfXfw8O.js → wasm-bridge-CDTF4ZQc.js} +1 -1
- package/dist/assets/workerHelpers-G7llXNMi.js +36 -0
- package/dist/index.html +2 -2
- package/package.json +15 -14
- package/src/components/viewer/BCFPanel.tsx +12 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +315 -154
- package/src/components/viewer/CommandPalette.tsx +0 -6
- package/src/components/viewer/DataConnector.tsx +489 -284
- package/src/components/viewer/ExportDialog.tsx +66 -6
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +44 -1
- package/src/components/viewer/MainToolbar.tsx +1 -5
- package/src/components/viewer/Viewport.tsx +42 -56
- package/src/components/viewer/ViewportContainer.tsx +3 -0
- package/src/components/viewer/ViewportOverlays.tsx +12 -10
- package/src/components/viewer/bcf/BCFOverlay.tsx +254 -0
- package/src/components/viewer/lists/ListPanel.tsx +0 -21
- package/src/components/viewer/lists/ListResultsTable.tsx +93 -5
- package/src/components/viewer/measureHandlers.ts +558 -0
- package/src/components/viewer/mouseHandlerTypes.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +86 -0
- package/src/components/viewer/useAnimationLoop.ts +116 -44
- package/src/components/viewer/useGeometryStreaming.ts +155 -367
- package/src/components/viewer/useKeyboardControls.ts +30 -46
- package/src/components/viewer/useMouseControls.ts +169 -695
- package/src/components/viewer/useRenderUpdates.ts +9 -59
- package/src/components/viewer/useTouchControls.ts +55 -40
- package/src/hooks/bcfIdLookup.ts +70 -0
- package/src/hooks/useBCF.ts +12 -31
- package/src/hooks/useIfcCache.ts +2 -20
- package/src/hooks/useIfcFederation.ts +5 -11
- package/src/hooks/useIfcLoader.ts +47 -56
- package/src/hooks/useIfcServer.ts +9 -1
- package/src/hooks/useKeyboardShortcuts.ts +0 -10
- package/src/hooks/useLatestRef.ts +24 -0
- package/src/sdk/adapters/export-adapter.ts +2 -2
- package/src/sdk/adapters/model-adapter.ts +1 -0
- package/src/sdk/local-backend.ts +2 -0
- package/src/store/basketVisibleSet.ts +12 -0
- package/src/store/slices/bcfSlice.ts +9 -0
- package/src/utils/loadingUtils.ts +46 -0
- package/src/utils/serverDataModel.ts +4 -3
- package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
- package/dist/assets/index-ax1X2WPd.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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
269
|
-
if (selectedTypes.length > 0
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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,
|
|
364
|
+
}, [selectedTypes, selectedStoreys, namePattern, filters, typeNameToEnums]);
|
|
330
365
|
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
//
|
|
343
|
-
const
|
|
344
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
//
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
385
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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-[
|
|
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="
|
|
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
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
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',
|