@ifc-lite/viewer 1.17.6 → 1.19.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 +20 -15
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +949 -0
- package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-RZy5c3Td.js} +1 -1
- package/dist/assets/decode-worker-Collf_X_.js +1320 -0
- package/dist/assets/{exporters-CcPS9MK5.js → exporters-BraHBeoi.js} +4194 -3025
- package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-DQEZB2rB.js} +1 -1
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-0XpVr_S5.css +1 -0
- package/dist/assets/{index-Bfms9I4A.js → index-BOi3BuUI.js} +46423 -31181
- package/dist/assets/index-XwKzDuw6.js +22 -0
- package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-CpBeOPQa.js} +1 -1
- package/dist/assets/sandbox-Baez7n-t.js +9682 -0
- package/dist/assets/{server-client-BuZK7OST.js → server-client-BB6cMAXE.js} +1 -1
- package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-CAYCUHbE.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +11 -10
- package/src/apache-arrow.d.ts +30 -0
- package/src/components/viewer/AddElementPanel.tsx +758 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
- package/src/components/viewer/ChatPanel.tsx +64 -2
- package/src/components/viewer/CommandPalette.tsx +56 -7
- package/src/components/viewer/EntityContextMenu.tsx +168 -4
- package/src/components/viewer/ExportChangesButton.tsx +25 -5
- package/src/components/viewer/ExportDialog.tsx +19 -1
- package/src/components/viewer/MainToolbar.tsx +73 -12
- package/src/components/viewer/PointCloudPanel.tsx +174 -0
- package/src/components/viewer/PropertiesPanel.tsx +222 -22
- package/src/components/viewer/SearchInline.tsx +669 -0
- package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
- package/src/components/viewer/SearchModal.filter.tsx +514 -0
- package/src/components/viewer/SearchModal.text.tsx +388 -0
- package/src/components/viewer/SearchModal.tsx +235 -0
- package/src/components/viewer/ToolOverlays.tsx +5 -0
- package/src/components/viewer/ViewerLayout.tsx +24 -4
- package/src/components/viewer/Viewport.tsx +29 -2
- package/src/components/viewer/ViewportContainer.tsx +45 -5
- package/src/components/viewer/ViewportOverlays.tsx +13 -2
- package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
- package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
- package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
- package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
- package/src/components/viewer/lists/ListPanel.tsx +14 -21
- package/src/components/viewer/properties/RawStepCard.tsx +332 -0
- package/src/components/viewer/properties/RawStepRow.tsx +261 -0
- package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
- package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
- package/src/components/viewer/properties/raw-step-format.ts +193 -0
- package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
- package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
- package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
- package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
- package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
- package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
- package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
- package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
- package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
- package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
- package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
- package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
- package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
- package/src/components/viewer/schedule/generate-schedule.ts +648 -0
- package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
- package/src/components/viewer/schedule/schedule-animator.ts +488 -0
- package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
- package/src/components/viewer/schedule/schedule-selection.ts +163 -0
- package/src/components/viewer/schedule/schedule-utils.ts +223 -0
- package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
- package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
- package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
- package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
- package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +446 -0
- package/src/components/viewer/tools/AddElementOverlay.tsx +581 -0
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
- package/src/components/viewer/usePointCloudSync.ts +98 -0
- package/src/hooks/ingest/pointCloudIngest.ts +391 -0
- package/src/hooks/ingest/viewerModelIngest.ts +32 -3
- package/src/hooks/useIfcFederation.ts +72 -3
- package/src/hooks/useIfcLoader.ts +89 -13
- package/src/hooks/useKeyboardShortcuts.ts +25 -0
- package/src/hooks/useSandbox.ts +1 -1
- package/src/hooks/useSearchIndex.ts +125 -0
- package/src/index.css +66 -0
- package/src/lib/llm/system-prompt.test.ts +14 -0
- package/src/lib/llm/system-prompt.ts +102 -1
- package/src/lib/llm/types.ts +6 -0
- package/src/lib/recent-files.ts +38 -4
- package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
- package/src/lib/scripts/templates/construction-schedule.ts +223 -0
- package/src/lib/scripts/templates.ts +7 -0
- package/src/lib/search/common-ifc-types.ts +36 -0
- package/src/lib/search/filter-evaluate.test.ts +537 -0
- package/src/lib/search/filter-evaluate.ts +610 -0
- package/src/lib/search/filter-rules.test.ts +119 -0
- package/src/lib/search/filter-rules.ts +198 -0
- package/src/lib/search/filter-schema.test.ts +233 -0
- package/src/lib/search/filter-schema.ts +146 -0
- package/src/lib/search/recent-searches.test.ts +116 -0
- package/src/lib/search/recent-searches.ts +93 -0
- package/src/lib/search/result-export.test.ts +101 -0
- package/src/lib/search/result-export.ts +104 -0
- package/src/lib/search/saved-filters.test.ts +118 -0
- package/src/lib/search/saved-filters.ts +154 -0
- package/src/lib/search/tier0-scan.test.ts +196 -0
- package/src/lib/search/tier0-scan.ts +237 -0
- package/src/lib/search/tier1-index.test.ts +242 -0
- package/src/lib/search/tier1-index.ts +448 -0
- package/src/sdk/adapters/export-adapter.test.ts +434 -1
- package/src/sdk/adapters/export-adapter.ts +404 -1
- package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
- package/src/sdk/adapters/export-schedule-splice.ts +87 -0
- package/src/sdk/adapters/model-compat.ts +8 -2
- package/src/sdk/adapters/schedule-adapter.ts +73 -0
- package/src/sdk/adapters/store-adapter.ts +201 -0
- package/src/sdk/adapters/visibility-adapter.ts +3 -0
- package/src/sdk/local-backend.ts +16 -8
- package/src/services/desktop-export.ts +3 -1
- package/src/services/desktop-native-metadata.ts +41 -18
- package/src/services/file-dialog.ts +8 -3
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/basketVisibleSet.ts +3 -0
- package/src/store/globalId.ts +4 -1
- package/src/store/index.ts +79 -1
- package/src/store/slices/addElementMeshes.ts +365 -0
- package/src/store/slices/addElementSlice.ts +275 -0
- package/src/store/slices/annotationsSlice.test.ts +133 -0
- package/src/store/slices/annotationsSlice.ts +251 -0
- package/src/store/slices/dataSlice.test.ts +23 -4
- package/src/store/slices/dataSlice.ts +1 -1
- package/src/store/slices/modelSlice.test.ts +67 -9
- package/src/store/slices/modelSlice.ts +39 -7
- package/src/store/slices/mutationSlice.ts +964 -3
- package/src/store/slices/overlayCompositor.test.ts +164 -0
- package/src/store/slices/overlaySlice.test.ts +93 -0
- package/src/store/slices/overlaySlice.ts +151 -0
- package/src/store/slices/pinboardSlice.test.ts +6 -1
- package/src/store/slices/playbackSlice.ts +128 -0
- package/src/store/slices/pointCloudSlice.ts +102 -0
- package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
- package/src/store/slices/schedule-edit-helpers.ts +179 -0
- package/src/store/slices/scheduleSlice.test.ts +694 -0
- package/src/store/slices/scheduleSlice.ts +1330 -0
- package/src/store/slices/searchSlice.test.ts +342 -0
- package/src/store/slices/searchSlice.ts +341 -0
- package/src/store/slices/selectionSlice.test.ts +46 -0
- package/src/store/slices/selectionSlice.ts +20 -0
- package/src/store/types.ts +7 -0
- package/src/store.ts +14 -0
- package/vite.config.ts +1 -0
- package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
- package/dist/assets/index-_bfZsDCC.css +0 -1
- package/dist/assets/sandbox-C8575tul.js +0 -5951
|
@@ -33,6 +33,12 @@ import {
|
|
|
33
33
|
parseIfcxViewerModel,
|
|
34
34
|
parseStepBufferViewerModel,
|
|
35
35
|
} from './ingest/viewerModelIngest.js';
|
|
36
|
+
import {
|
|
37
|
+
detectPointCloudFormat,
|
|
38
|
+
ingestPointCloud,
|
|
39
|
+
type PointCloudFormat,
|
|
40
|
+
} from './ingest/pointCloudIngest.js';
|
|
41
|
+
import { getGlobalRenderer } from './useBCF.js';
|
|
36
42
|
import { readNativeFile, type NativeFileHandle } from '../services/file-dialog.js';
|
|
37
43
|
import { getEffectiveGeoreference, type GeorefMutationDataLike } from '../lib/geo/effective-georef.js';
|
|
38
44
|
|
|
@@ -439,14 +445,53 @@ export function useIfcFederation() {
|
|
|
439
445
|
: await file.arrayBuffer();
|
|
440
446
|
const fileSizeMB = buffer.byteLength / (1024 * 1024);
|
|
441
447
|
|
|
448
|
+
// Detect point cloud formats first — we never run them through
|
|
449
|
+
// detectFormat() (which is IFC-shaped) because they have their own
|
|
450
|
+
// streaming pipeline that bypasses geometryResult.meshes.
|
|
451
|
+
const pointCloudFormat = detectPointCloudFormat(file.name, buffer);
|
|
452
|
+
|
|
442
453
|
// Detect file format
|
|
443
|
-
const format
|
|
454
|
+
const format: ReturnType<typeof detectFormat> | PointCloudFormat =
|
|
455
|
+
pointCloudFormat ?? detectFormat(buffer);
|
|
444
456
|
|
|
445
457
|
let parsedDataStore: IfcDataStore | null = null;
|
|
446
458
|
let parsedGeometry: FederatedModel['geometryResult'] = null;
|
|
447
459
|
let schemaVersion: SchemaVersion = 'IFC4';
|
|
448
|
-
|
|
449
|
-
|
|
460
|
+
// Renderer handle for streamed point clouds; surviving model lifecycle
|
|
461
|
+
// depends on persisting it onto the FederatedModel record.
|
|
462
|
+
let pointCloudHandleId: number | undefined;
|
|
463
|
+
|
|
464
|
+
if (format === 'las' || format === 'laz' || format === 'ply' || format === 'pcd' || format === 'e57') {
|
|
465
|
+
const renderer = getGlobalRenderer();
|
|
466
|
+
if (!renderer) {
|
|
467
|
+
setError('Renderer not initialised — try again after the viewer mounts.');
|
|
468
|
+
setLoading(false);
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
setProgress({ phase: `Streaming ${format.toUpperCase()}`, percent: 5 });
|
|
472
|
+
const blob = isNativeFileHandle(file)
|
|
473
|
+
? new Blob([buffer])
|
|
474
|
+
: (file as File);
|
|
475
|
+
const incCount = useViewerStore.getState().incrementPointCloudAssetCount;
|
|
476
|
+
const ingest = ingestPointCloud({
|
|
477
|
+
format,
|
|
478
|
+
blob,
|
|
479
|
+
fileName: file.name,
|
|
480
|
+
buffer,
|
|
481
|
+
renderer,
|
|
482
|
+
onProgress: setProgress,
|
|
483
|
+
onAssetCountDelta: incCount,
|
|
484
|
+
});
|
|
485
|
+
// ingest.done rejects on stream errors; ingestPointCloud's onError
|
|
486
|
+
// callback already calls removePointCloudAsset + incCount(-1), so
|
|
487
|
+
// the outer catch must NOT repeat that cleanup or the count goes
|
|
488
|
+
// negative when other point clouds are still loaded.
|
|
489
|
+
await ingest.done;
|
|
490
|
+
parsedDataStore = ingest.dataStore;
|
|
491
|
+
parsedGeometry = ingest.geometryResult;
|
|
492
|
+
schemaVersion = ingest.schemaVersion;
|
|
493
|
+
pointCloudHandleId = ingest.rendererHandle.id;
|
|
494
|
+
} else if (format === 'ifcx') {
|
|
450
495
|
setProgress({ phase: 'Parsing IFCX (client-side)', percent: 10 });
|
|
451
496
|
try {
|
|
452
497
|
const result = await parseIfcxViewerModel(buffer, setProgress);
|
|
@@ -541,6 +586,29 @@ export function useIfcFederation() {
|
|
|
541
586
|
for (const mesh of parsedGeometry.meshes) {
|
|
542
587
|
mesh.expressId = mesh.expressId + idOffset;
|
|
543
588
|
}
|
|
589
|
+
// Point clouds need the same offset so picking / isolation /
|
|
590
|
+
// property lookup resolve through the FederationRegistry's
|
|
591
|
+
// global ID space — otherwise two pointcloud models with the
|
|
592
|
+
// same local expressId collide.
|
|
593
|
+
for (const asset of parsedGeometry.pointClouds ?? []) {
|
|
594
|
+
asset.expressId = asset.expressId + idOffset;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// Streamed point cloud: the GPU asset was opened with a synthetic
|
|
598
|
+
// local expressId. After registerModelOffset() hands us an
|
|
599
|
+
// idOffset, the renderer needs to emit the post-offset globalId
|
|
600
|
+
// in picking + selection outputs — otherwise picks resolve to
|
|
601
|
+
// the local id and collide across federated models. The shader
|
|
602
|
+
// reads expressId from a per-asset uniform (`flags.x`) so this
|
|
603
|
+
// is just a metadata update; no GPU buffer rewrite.
|
|
604
|
+
if (idOffset > 0 && pointCloudHandleId !== undefined) {
|
|
605
|
+
const renderer = getGlobalRenderer();
|
|
606
|
+
if (renderer && parsedGeometry.pointClouds && parsedGeometry.pointClouds.length > 0) {
|
|
607
|
+
// Use the asset that's already had idOffset folded in above
|
|
608
|
+
// as the source of truth for the global id.
|
|
609
|
+
const asset = parsedGeometry.pointClouds[0];
|
|
610
|
+
renderer.relabelPointCloudAsset({ id: pointCloudHandleId }, asset.expressId);
|
|
611
|
+
}
|
|
544
612
|
}
|
|
545
613
|
|
|
546
614
|
// =========================================================================
|
|
@@ -567,6 +635,7 @@ export function useIfcFederation() {
|
|
|
567
635
|
sourceFile: file,
|
|
568
636
|
idOffset,
|
|
569
637
|
maxExpressId,
|
|
638
|
+
pointCloudHandleId,
|
|
570
639
|
};
|
|
571
640
|
|
|
572
641
|
// Add to store
|
|
@@ -46,6 +46,8 @@ import { useIfcCache, getCached } from './useIfcCache.js';
|
|
|
46
46
|
import { useIfcServer } from './useIfcServer.js';
|
|
47
47
|
|
|
48
48
|
import { getMaxExpressId, parseGlbViewerModel, parseIfcxViewerModel } from './ingest/viewerModelIngest.js';
|
|
49
|
+
import { detectPointCloudFormat, ingestPointCloud } from './ingest/pointCloudIngest.js';
|
|
50
|
+
import { getGlobalRenderer } from './useBCF.js';
|
|
49
51
|
|
|
50
52
|
/**
|
|
51
53
|
* Compute a fast content fingerprint from the first and last 4KB of a buffer.
|
|
@@ -253,7 +255,7 @@ export function useIfcLoader() {
|
|
|
253
255
|
dataStore: IfcDataStore | null,
|
|
254
256
|
geometryResult: { meshes: MeshData[]; totalVertices: number; totalTriangles: number; coordinateInfo: CoordinateInfo } | null,
|
|
255
257
|
schemaVersion: 'IFC2X3' | 'IFC4' | 'IFC4X3' | 'IFC5',
|
|
256
|
-
patch?: { loadState?: 'pending' | 'streaming-geometry' | 'hydrating-metadata' | 'complete' | 'error'; cacheState?: 'none' | 'hit' | 'miss' | 'writing'; loadError?: string | null },
|
|
258
|
+
patch?: { loadState?: 'pending' | 'streaming-geometry' | 'hydrating-metadata' | 'complete' | 'error'; cacheState?: 'none' | 'hit' | 'miss' | 'writing'; loadError?: string | null; pointCloudHandleId?: number },
|
|
257
259
|
) => {
|
|
258
260
|
let idOffset = 0;
|
|
259
261
|
let maxExpressId = 0;
|
|
@@ -271,6 +273,7 @@ export function useIfcLoader() {
|
|
|
271
273
|
loadState: patch?.loadState ?? 'complete',
|
|
272
274
|
cacheState: patch?.cacheState ?? 'none',
|
|
273
275
|
loadError: patch?.loadError ?? null,
|
|
276
|
+
pointCloudHandleId: patch?.pointCloudHandleId,
|
|
274
277
|
});
|
|
275
278
|
};
|
|
276
279
|
const getSchemaVersion = (dataStore: IfcDataStore | null): 'IFC2X3' | 'IFC4' | 'IFC4X3' | 'IFC5' => {
|
|
@@ -281,15 +284,23 @@ export function useIfcLoader() {
|
|
|
281
284
|
return 'IFC2X3';
|
|
282
285
|
};
|
|
283
286
|
|
|
287
|
+
// Native renderer streaming path is currently disabled — the
|
|
288
|
+
// `huge native file` block further down handles real desktop
|
|
289
|
+
// streaming. This branch is retained as a scaffold for the future
|
|
290
|
+
// always-on native renderer integration.
|
|
291
|
+
const NATIVE_RENDERER_PATH_ENABLED = false as boolean;
|
|
284
292
|
if (
|
|
293
|
+
NATIVE_RENDERER_PATH_ENABLED &&
|
|
285
294
|
isNativeFileHandle(file) &&
|
|
286
|
-
fileName.toLowerCase().endsWith('.ifc')
|
|
287
|
-
false
|
|
295
|
+
fileName.toLowerCase().endsWith('.ifc')
|
|
288
296
|
) {
|
|
297
|
+
// Re-narrow `file` for the body — TS occasionally drops the
|
|
298
|
+
// type-predicate result inside a dead branch.
|
|
299
|
+
const nativeFile: NativeFileHandle = file;
|
|
289
300
|
const harnessRequest = getActiveHarnessRequest();
|
|
290
|
-
const nativeCacheKey = computeNativeCacheKey(
|
|
291
|
-
const shouldUseNativeCache =
|
|
292
|
-
const hugeNativeMode =
|
|
301
|
+
const nativeCacheKey = computeNativeCacheKey(nativeFile);
|
|
302
|
+
const shouldUseNativeCache = nativeFile.size >= CACHE_SIZE_THRESHOLD;
|
|
303
|
+
const hugeNativeMode = nativeFile.size >= HUGE_NATIVE_FILE_THRESHOLD;
|
|
293
304
|
let firstBatchWaitMs: number | null = null;
|
|
294
305
|
let firstVisibleGeometryMs: number | null = null;
|
|
295
306
|
let modelOpenMs: number | null = null;
|
|
@@ -312,7 +323,7 @@ export function useIfcLoader() {
|
|
|
312
323
|
let nativeGeometryCacheHit = false;
|
|
313
324
|
let nativeMetadataSnapshotHit = false;
|
|
314
325
|
let nativeMetadataSource: 'snapshot' | 'ifc-parse' = 'ifc-parse';
|
|
315
|
-
let nativeMetadataStartGate
|
|
326
|
+
let nativeMetadataStartGate = 'immediate' as 'immediate' | 'afterInteractiveGeometry' | 'afterGeometryComplete';
|
|
316
327
|
let finalCoordinateInfo: CoordinateInfo | null = null;
|
|
317
328
|
|
|
318
329
|
console.log(`[useIfc] Native renderer load: ${fileName}, size: ${fileSizeMB.toFixed(2)}MB`);
|
|
@@ -727,7 +738,7 @@ export function useIfcLoader() {
|
|
|
727
738
|
let fullNativeDataStore: IfcDataStore | null = null;
|
|
728
739
|
let nativeLoadStage: 'open' | 'streamGeometry' | 'finalizeGeometry' | 'hydrateMetadata' | 'complete' = 'open';
|
|
729
740
|
let nativeMetadataSource: 'snapshot' | 'ifc-parse' = 'ifc-parse';
|
|
730
|
-
let nativeMetadataStartGate
|
|
741
|
+
let nativeMetadataStartGate = 'immediate' as 'immediate' | 'afterInteractiveGeometry' | 'afterGeometryComplete';
|
|
731
742
|
|
|
732
743
|
setGeometryResult(null);
|
|
733
744
|
|
|
@@ -1552,8 +1563,69 @@ export function useIfcLoader() {
|
|
|
1552
1563
|
const fileReadMs = performance.now() - fileReadStart;
|
|
1553
1564
|
console.log(`[useIfc] File: ${file.name}, size: ${fileSizeMB.toFixed(2)}MB, read in ${fileReadMs.toFixed(0)}ms`);
|
|
1554
1565
|
|
|
1555
|
-
// Detect file format (IFCX/IFC5 vs IFC4 STEP vs GLB)
|
|
1556
|
-
const
|
|
1566
|
+
// Detect file format (IFCX/IFC5 vs IFC4 STEP vs GLB vs LAS/LAZ)
|
|
1567
|
+
const pointCloudFormat = detectPointCloudFormat(file.name, buffer);
|
|
1568
|
+
const format = pointCloudFormat ?? detectFormat(buffer);
|
|
1569
|
+
|
|
1570
|
+
// LAS / LAZ point clouds: stream chunks straight to the renderer.
|
|
1571
|
+
// No on-disk cache, no server upload — the data goes worker → GPU.
|
|
1572
|
+
if (format === 'las' || format === 'laz' || format === 'ply' || format === 'pcd' || format === 'e57') {
|
|
1573
|
+
const renderer = getGlobalRenderer();
|
|
1574
|
+
if (!renderer) {
|
|
1575
|
+
setError('Renderer not initialised — try again after the viewer mounts.');
|
|
1576
|
+
updateModel(primaryModelId, { loadState: 'error', loadError: 'renderer-missing' });
|
|
1577
|
+
setLoading(false);
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
setProgress({ phase: `Streaming ${format.toUpperCase()}`, percent: 5 });
|
|
1581
|
+
setGeometryStreamingActive(false);
|
|
1582
|
+
const blob = isNativeFileHandle(file) ? new Blob([buffer]) : (file as File);
|
|
1583
|
+
const incCount = useViewerStore.getState().incrementPointCloudAssetCount;
|
|
1584
|
+
const ingest = ingestPointCloud({
|
|
1585
|
+
format,
|
|
1586
|
+
blob,
|
|
1587
|
+
fileName: file.name,
|
|
1588
|
+
buffer,
|
|
1589
|
+
renderer,
|
|
1590
|
+
onProgress: setProgress,
|
|
1591
|
+
onAssetCountDelta: incCount,
|
|
1592
|
+
});
|
|
1593
|
+
// ingestPointCloud's onError callback already runs renderer cleanup
|
|
1594
|
+
// + incCount(-1); the outer catch must NOT repeat them or the
|
|
1595
|
+
// pointCloudAssetCount will go negative.
|
|
1596
|
+
try {
|
|
1597
|
+
await ingest.done;
|
|
1598
|
+
} catch (err) {
|
|
1599
|
+
// Bail without touching store/UI state if a newer load
|
|
1600
|
+
// session has already started — the more recent flow owns
|
|
1601
|
+
// the spinner / model record now. Free the renderer handle
|
|
1602
|
+
// so we don't leak the half-streamed asset.
|
|
1603
|
+
if (loadSessionRef.current !== currentSession) {
|
|
1604
|
+
renderer.removePointCloudAsset(ingest.rendererHandle);
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1608
|
+
updateModel(primaryModelId, { loadState: 'error', loadError: message });
|
|
1609
|
+
setError(`${format.toUpperCase()} parsing failed: ${message}`);
|
|
1610
|
+
setLoading(false);
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
if (loadSessionRef.current !== currentSession) {
|
|
1614
|
+
// A newer load already began. Drop our streamed asset and
|
|
1615
|
+
// skip every store/UI mutation so we don't overwrite the
|
|
1616
|
+
// newer model's state.
|
|
1617
|
+
renderer.removePointCloudAsset(ingest.rendererHandle);
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
setGeometryResult(ingest.geometryResult);
|
|
1621
|
+
setIfcDataStore(ingest.dataStore);
|
|
1622
|
+
finalizePrimaryModel(ingest.dataStore, ingest.geometryResult, ingest.schemaVersion, {
|
|
1623
|
+
pointCloudHandleId: ingest.rendererHandle.id,
|
|
1624
|
+
});
|
|
1625
|
+
setProgress({ phase: 'Complete', percent: 100 });
|
|
1626
|
+
setLoading(false);
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1557
1629
|
|
|
1558
1630
|
// IFCX files must be parsed client-side (server only supports IFC4 STEP)
|
|
1559
1631
|
if (format === 'ifcx') {
|
|
@@ -1638,8 +1710,10 @@ export function useIfcLoader() {
|
|
|
1638
1710
|
}
|
|
1639
1711
|
|
|
1640
1712
|
// Try server parsing first (enabled by default for multi-core performance)
|
|
1641
|
-
// Only for IFC4 STEP files (server doesn't support IFCX)
|
|
1642
|
-
|
|
1713
|
+
// Only for IFC4 STEP files (server doesn't support IFCX). Native
|
|
1714
|
+
// file handles (Tauri) don't have an HTTP-uploadable body, so skip
|
|
1715
|
+
// the server path and fall through to the WASM loader.
|
|
1716
|
+
if (format === 'ifc' && USE_SERVER && SERVER_URL && SERVER_URL !== '' && !isNativeFileHandle(file)) {
|
|
1643
1717
|
// Pass buffer directly - server uses File object for parsing, buffer is only for size checks
|
|
1644
1718
|
const serverSuccess = await loadFromServer(file, buffer, () => loadSessionRef.current !== currentSession);
|
|
1645
1719
|
if (serverSuccess) {
|
|
@@ -1792,7 +1866,9 @@ export function useIfcLoader() {
|
|
|
1792
1866
|
if (geometryIteratorClosed || typeof geometryIterator.return !== 'function') return;
|
|
1793
1867
|
geometryIteratorClosed = true;
|
|
1794
1868
|
try {
|
|
1795
|
-
|
|
1869
|
+
// `AsyncIterator.return()` is signed as taking a value in
|
|
1870
|
+
// current TS libs; callers conventionally pass `undefined`.
|
|
1871
|
+
await geometryIterator.return(undefined);
|
|
1796
1872
|
} catch {
|
|
1797
1873
|
// Ignore iterator shutdown failures during recovery.
|
|
1798
1874
|
}
|
|
@@ -88,6 +88,10 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
|
88
88
|
e.preventDefault();
|
|
89
89
|
setActiveTool('section');
|
|
90
90
|
}
|
|
91
|
+
if (key === 'p' && !ctrl && !shift) {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
setActiveTool('annotate');
|
|
94
|
+
}
|
|
91
95
|
|
|
92
96
|
// Basket controls (automatic context source)
|
|
93
97
|
// I = Isolate from current context
|
|
@@ -150,6 +154,26 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
|
150
154
|
resetVisibilityForHomeFromStore();
|
|
151
155
|
}
|
|
152
156
|
|
|
157
|
+
// Add-element tool shortcuts — Enter commits an in-progress slab
|
|
158
|
+
// polygon; Esc clears any pending points before falling through to
|
|
159
|
+
// the global Esc handler (which exits the tool).
|
|
160
|
+
if (activeTool === 'addElement') {
|
|
161
|
+
const state = useViewerStore.getState();
|
|
162
|
+
const polygonable = ['slab', 'roof', 'plate', 'space'].includes(state.addElementType);
|
|
163
|
+
if (key === 'enter' && polygonable && state.addElementSlabMode === 'polygon') {
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
// Lazy import keeps this module out of the keyboard hook's
|
|
166
|
+
// synchronous bundle (the close handler pulls in toast).
|
|
167
|
+
import('@/components/viewer/selectionHandlers').then((mod) => mod.commitAddElementSlabPolygon());
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (key === 'escape' && state.addElementPendingPoints.length > 0) {
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
state.clearAddElementPending();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
153
177
|
// Measure tool shortcuts
|
|
154
178
|
if (activeTool === 'measure') {
|
|
155
179
|
// Cancel active measurement with ESC
|
|
@@ -243,6 +267,7 @@ export const KEYBOARD_SHORTCUTS = [
|
|
|
243
267
|
{ key: 'V', description: 'Select tool', category: 'Tools' },
|
|
244
268
|
{ key: 'C', description: 'Walk mode', category: 'Tools' },
|
|
245
269
|
{ key: 'M', description: 'Measure tool', category: 'Tools' },
|
|
270
|
+
{ key: 'P', description: 'Annotate tool — drop a pin with a note', category: 'Tools' },
|
|
246
271
|
{ key: 'X', description: 'Section tool', category: 'Tools' },
|
|
247
272
|
{ key: 'S', description: 'Toggle snapping (Measure tool)', category: 'Tools' },
|
|
248
273
|
{ key: 'Esc', description: 'Cancel measurement (Measure tool)', category: 'Tools' },
|
package/src/hooks/useSandbox.ts
CHANGED
|
@@ -165,7 +165,7 @@ export function useSandbox(config?: SandboxConfig) {
|
|
|
165
165
|
// Create a fresh sandbox for every execution — full isolation
|
|
166
166
|
const { createSandbox } = await import('@ifc-lite/sandbox');
|
|
167
167
|
sandbox = await createSandbox(bim, {
|
|
168
|
-
permissions: { model: true, query: true, viewer: true, mutate: true, lens: true, export: true, files: true, ...config?.permissions },
|
|
168
|
+
permissions: { model: true, query: true, viewer: true, mutate: true, store: true, lens: true, export: true, files: true, ...config?.permissions },
|
|
169
169
|
limits: { timeoutMs: 30_000, ...config?.limits },
|
|
170
170
|
});
|
|
171
171
|
activeSandboxRef.current = sandbox;
|
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
* useSearchIndex — lazy builder for the Tier-1 search index.
|
|
7
|
+
*
|
|
8
|
+
* Mount once near the root of the viewer shell (currently `SearchInline`,
|
|
9
|
+
* since it's always rendered once the toolbar is up). The hook watches
|
|
10
|
+
* the federated `models` map; for each model with a populated
|
|
11
|
+
* `ifcDataStore` that doesn't yet have a Tier-1 record, it spawns a
|
|
12
|
+
* chunked build. Models that disappear get their index record dropped.
|
|
13
|
+
*
|
|
14
|
+
* Load-perf guarantee: the build NEVER runs during the actual IFC load
|
|
15
|
+
* because `ifcDataStore` is non-null only after the parser reports the
|
|
16
|
+
* model is ready (`onSpatialReady` + geometry). The build itself yields
|
|
17
|
+
* to the event loop every `DEFAULT_CHUNK_SIZE` rows so a 4M-entity
|
|
18
|
+
* index doesn't hog the main thread.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useEffect, useRef } from 'react';
|
|
22
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
23
|
+
import { useViewerStore } from '@/store';
|
|
24
|
+
import { buildTier1Index } from '@/lib/search/tier1-index';
|
|
25
|
+
|
|
26
|
+
export function useSearchIndex(): void {
|
|
27
|
+
const {
|
|
28
|
+
models,
|
|
29
|
+
searchIndexes,
|
|
30
|
+
setSearchIndexRecord,
|
|
31
|
+
removeSearchIndexRecord,
|
|
32
|
+
searchFilterSchema,
|
|
33
|
+
removeFilterSchema,
|
|
34
|
+
} = useViewerStore(
|
|
35
|
+
useShallow((s) => ({
|
|
36
|
+
models: s.models,
|
|
37
|
+
searchIndexes: s.searchIndexes,
|
|
38
|
+
setSearchIndexRecord: s.setSearchIndexRecord,
|
|
39
|
+
removeSearchIndexRecord: s.removeSearchIndexRecord,
|
|
40
|
+
searchFilterSchema: s.searchFilterSchema,
|
|
41
|
+
removeFilterSchema: s.removeFilterSchema,
|
|
42
|
+
})),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// One AbortController per in-flight build. Lets us cancel cleanly when a
|
|
46
|
+
// model is removed mid-build or when the component unmounts.
|
|
47
|
+
const controllersRef = useRef<Map<string, AbortController>>(new Map());
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const controllers = controllersRef.current;
|
|
51
|
+
|
|
52
|
+
// Drop records / abort builds for models that no longer exist.
|
|
53
|
+
for (const modelId of Array.from(searchIndexes.keys())) {
|
|
54
|
+
if (!models.has(modelId)) {
|
|
55
|
+
controllers.get(modelId)?.abort();
|
|
56
|
+
controllers.delete(modelId);
|
|
57
|
+
removeSearchIndexRecord(modelId);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Drop the filter-schema cache for departed models too. Stale entries
|
|
62
|
+
// would surface in the chip dropdowns the next time a model with the
|
|
63
|
+
// same id loaded (e.g. user reopens a different file as model_0).
|
|
64
|
+
for (const modelId of Array.from(searchFilterSchema.keys())) {
|
|
65
|
+
if (!models.has(modelId)) removeFilterSchema(modelId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Kick off builds for models that are loaded but not yet indexed.
|
|
69
|
+
for (const [modelId, model] of models) {
|
|
70
|
+
if (!model.ifcDataStore) continue;
|
|
71
|
+
const existing = searchIndexes.get(modelId);
|
|
72
|
+
if (existing && existing.status !== 'pending') continue;
|
|
73
|
+
if (controllers.has(modelId)) continue;
|
|
74
|
+
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
controllers.set(modelId, controller);
|
|
77
|
+
|
|
78
|
+
setSearchIndexRecord(modelId, { status: 'building', progress: 0 });
|
|
79
|
+
|
|
80
|
+
// Fire-and-forget — the build is cancellable via the controller, and
|
|
81
|
+
// the completion handlers update the store without needing a ref.
|
|
82
|
+
void buildTier1Index(modelId, model.ifcDataStore, {
|
|
83
|
+
signal: controller.signal,
|
|
84
|
+
onProgress: (done, total) => {
|
|
85
|
+
if (controller.signal.aborted) return;
|
|
86
|
+
const progress = total > 0 ? done / total : 1;
|
|
87
|
+
setSearchIndexRecord(modelId, { status: 'building', progress });
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
.then((index) => {
|
|
91
|
+
if (controller.signal.aborted) return;
|
|
92
|
+
controllers.delete(modelId);
|
|
93
|
+
setSearchIndexRecord(modelId, { status: 'ready', index, progress: 1 });
|
|
94
|
+
})
|
|
95
|
+
.catch((err: unknown) => {
|
|
96
|
+
controllers.delete(modelId);
|
|
97
|
+
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
98
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
99
|
+
// Don't set a 'ready' record — Tier-0 fallback stays live.
|
|
100
|
+
console.warn(`[useSearchIndex] build failed for ${modelId}:`, message);
|
|
101
|
+
setSearchIndexRecord(modelId, { status: 'error', error: message });
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// On unmount OR next effect pass, abort everything. The effect re-runs
|
|
106
|
+
// only when `models` / `searchIndexes` changes, so steady-state
|
|
107
|
+
// incurs no abort — the `controllers.has(modelId)` guard above makes
|
|
108
|
+
// re-entry idempotent.
|
|
109
|
+
return () => {
|
|
110
|
+
// Intentionally NOT aborting everything on every re-render — only
|
|
111
|
+
// models that went missing got aborted above. The real cleanup is
|
|
112
|
+
// the component-unmount pass below.
|
|
113
|
+
};
|
|
114
|
+
}, [models, searchIndexes, setSearchIndexRecord, removeSearchIndexRecord, searchFilterSchema, removeFilterSchema]);
|
|
115
|
+
|
|
116
|
+
// Abort any in-flight builds when the consumer unmounts. Separate effect
|
|
117
|
+
// so it only fires on unmount (no deps).
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
const controllers = controllersRef.current;
|
|
120
|
+
return () => {
|
|
121
|
+
for (const c of controllers.values()) c.abort();
|
|
122
|
+
controllers.clear();
|
|
123
|
+
};
|
|
124
|
+
}, []);
|
|
125
|
+
}
|
package/src/index.css
CHANGED
|
@@ -379,6 +379,43 @@ body {
|
|
|
379
379
|
color: var(--color-primary) !important;
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
/* Raw STEP tab — terminal-flavoured dev affordance.
|
|
383
|
+
Compact (icon-only </>), separates visually from the three "human"
|
|
384
|
+
tabs with a left divider and a green active state that nods to a
|
|
385
|
+
shell cursor. Stays width-stable at narrow panel widths because
|
|
386
|
+
it explicitly opts out of `flex: 1` (uses an `auto`-ish width
|
|
387
|
+
driven by the </> glyph plus padding). */
|
|
388
|
+
.properties-tab-trigger.raw-step-tab-trigger {
|
|
389
|
+
flex: 0 0 auto;
|
|
390
|
+
min-width: 2.25rem;
|
|
391
|
+
padding-left: 0.5rem;
|
|
392
|
+
padding-right: 0.5rem;
|
|
393
|
+
border-left: 1px solid var(--tabs-border);
|
|
394
|
+
letter-spacing: 0;
|
|
395
|
+
color: color-mix(in srgb, var(--tab-text) 70%, transparent) !important;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.properties-tab-trigger.raw-step-tab-trigger:hover {
|
|
399
|
+
background-color: color-mix(in srgb, #10b981 16%, var(--tabs-bg)) !important;
|
|
400
|
+
color: #047857 !important;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.dark .properties-tab-trigger.raw-step-tab-trigger:hover {
|
|
404
|
+
background-color: color-mix(in srgb, #10b981 22%, var(--tabs-bg)) !important;
|
|
405
|
+
color: #34d399 !important;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.properties-tab-trigger.raw-step-tab-trigger[data-state="active"] {
|
|
409
|
+
background-color: color-mix(in srgb, #10b981 14%, var(--tab-active-bg)) !important;
|
|
410
|
+
color: #047857 !important;
|
|
411
|
+
border-top-color: #10b981 !important;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
.dark .properties-tab-trigger.raw-step-tab-trigger[data-state="active"] {
|
|
415
|
+
color: #34d399 !important;
|
|
416
|
+
border-top-color: #34d399 !important;
|
|
417
|
+
}
|
|
418
|
+
|
|
382
419
|
/* Quantity cards - cyan accent */
|
|
383
420
|
.dark .border-blue-200,
|
|
384
421
|
.dark .border-blue-800,
|
|
@@ -997,6 +1034,35 @@ body {
|
|
|
997
1034
|
cursor: not-allowed;
|
|
998
1035
|
}
|
|
999
1036
|
|
|
1037
|
+
/* Annotation pin idle introduction — fires once on mount, then settles.
|
|
1038
|
+
Driven by a `prefers-reduced-motion` guard so accessibility users
|
|
1039
|
+
don't get hit by the pulse. */
|
|
1040
|
+
@keyframes annotation-pin-idle {
|
|
1041
|
+
0% {
|
|
1042
|
+
transform: scale(0.7);
|
|
1043
|
+
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55);
|
|
1044
|
+
}
|
|
1045
|
+
60% {
|
|
1046
|
+
transform: scale(1.08);
|
|
1047
|
+
box-shadow: 0 0 0 8px rgba(245, 158, 11, 0);
|
|
1048
|
+
}
|
|
1049
|
+
100% {
|
|
1050
|
+
transform: scale(1);
|
|
1051
|
+
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35),
|
|
1052
|
+
0 0 0 1px rgba(0, 0, 0, 0.15);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
.annotation-pin-idle {
|
|
1057
|
+
animation: annotation-pin-idle 360ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1061
|
+
.annotation-pin-idle {
|
|
1062
|
+
animation: none;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1000
1066
|
/* Loading skeleton animation */
|
|
1001
1067
|
@keyframes skeleton-pulse {
|
|
1002
1068
|
0%, 100% {
|
|
@@ -91,6 +91,20 @@ test('system prompt includes selected entity IFC context when provided', () => {
|
|
|
91
91
|
assert.match(prompt, /Selected entities: Tower: IfcCurtainWall "Facade Panel A", kind=occurrence, storey=Level 10@31.5m, psets=Pset_CurtainWallCommon, typePsets=Pset_CurtainWallTypeCommon, qsets=Qto_CurtainWallBaseQuantities, material=Aluminium, classifications=A-123 \| Tower: IfcWallType "Exterior Wall Type", kind=type, psets=Pset_WallCommon, classifications=A-WALL/);
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
test('system prompt includes the BIM.STORE cheat sheet and routing rule', () => {
|
|
95
|
+
const prompt = buildSystemPrompt();
|
|
96
|
+
|
|
97
|
+
// Section heading + the three method examples
|
|
98
|
+
assert.match(prompt, /## BIM\.STORE CHEAT SHEET/);
|
|
99
|
+
assert.match(prompt, /bim\.store\.setPositionalAttribute\(profile, 3, 0\.6\)/);
|
|
100
|
+
assert.match(prompt, /bim\.store\.addEntity\("arch"/);
|
|
101
|
+
assert.match(prompt, /bim\.store\.removeEntity\(unwantedRef\)/);
|
|
102
|
+
|
|
103
|
+
// Routing rule disambiguating store / mutate / create
|
|
104
|
+
assert.match(prompt, /bim\.store\.setPositionalAttribute\(entity, index, value\)`? for positional STEP-argument edits/);
|
|
105
|
+
assert.match(prompt, /Do NOT use `bim\.create` for these/);
|
|
106
|
+
});
|
|
107
|
+
|
|
94
108
|
test('system prompt includes method-specific create contract guidance', () => {
|
|
95
109
|
const prompt = buildSystemPrompt();
|
|
96
110
|
assert.match(prompt, /BIM\.CREATE CONTRACT CHEAT SHEET/);
|