@ifc-lite/viewer 1.25.0 → 1.25.2
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 +79 -84
- package/CHANGELOG.md +60 -0
- package/dist/assets/{basketViewActivator-CU8_toGq.js → basketViewActivator-CTgyKI3U.js} +6 -6
- package/dist/assets/{bcf-DXGDhw56.js → bcf-7jQby1qi.js} +1 -1
- package/dist/assets/{deflate-Bb1_H2Yf.js → deflate-Cfp9t1Df.js} +1 -1
- package/dist/assets/exporters-DfSvJPi4.js +4660 -0
- package/dist/assets/geometry.worker-Cyn5BybV.js +1 -0
- package/dist/assets/{geotiff-y0ZxbRJd.js → geotiff-xZoE8BkO.js} +10 -10
- package/dist/assets/{ids-DruUNtfD.js → ids-Cu73hD0Y.js} +21 -21
- package/dist/assets/ifc-lite_bg-ksLBP5cA.wasm +0 -0
- package/dist/assets/{index-Dr88ZlSY.js → index-WSbA5iy6.js} +31959 -31608
- package/dist/assets/{jpeg-B3_loqFe.js → jpeg-DhwFEbqb.js} +1 -1
- package/dist/assets/{lerc-nkwS8ZUe.js → lerc-Dz6BXOVb.js} +1 -1
- package/dist/assets/{lzw-D3cW5Wpg.js → lzw-C9z0fG2o.js} +1 -1
- package/dist/assets/{native-bridge-BcYJooq8.js → native-bridge-RvDmzO-2.js} +1 -1
- package/dist/assets/{packbits-DDN4xzB5.js → packbits-jfwifz7C.js} +1 -1
- package/dist/assets/parser.worker-C594dWxH.js +182 -0
- package/dist/assets/raw-R2QfzPAR.js +1 -0
- package/dist/assets/{sandbox-DETNEyQb.js → sandbox-DDSZ7rek.js} +2450 -2260
- package/dist/assets/{server-client-CmzJOeS7.js → server-client-Ctk8_Bof.js} +1 -1
- package/dist/assets/{webimage-CBjgg4up.js → webimage-XFHVyVtC.js} +1 -1
- package/dist/assets/{zstd-C8oQ6qdS.js → zstd-3q5qcl5V.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +22 -21
- package/src/App.tsx +4 -0
- package/src/components/viewer/BCFPanel.tsx +8 -1
- package/src/components/viewer/CommandPalette.tsx +5 -1
- package/src/components/viewer/MainToolbar.tsx +41 -19
- package/src/components/viewer/Section2DPanel.tsx +6 -2
- package/src/components/viewer/Viewport.tsx +48 -3
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +24 -0
- package/src/components/viewer/hierarchy/ifc-icons.ts +60 -0
- package/src/components/viewer/useGeometryStreaming.ts +113 -18
- package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +61 -0
- package/src/hooks/ingest/resolveDataStoreOrAbort.ts +28 -0
- package/src/hooks/ingest/viewerModelIngest.ts +55 -11
- package/src/hooks/useBCF.ts +98 -16
- package/src/hooks/useDrawingGeneration.ts +149 -3
- package/src/hooks/useIfcCache.ts +44 -18
- package/src/hooks/useIfcLoader.ts +1 -23
- package/src/hooks/useSymbolicAnnotations.ts +240 -61
- package/src/store/constants.ts +19 -3
- package/src/store/index.ts +1 -0
- package/src/store/slices/visibilitySlice.ts +2 -1
- package/src/store/types.ts +9 -0
- package/src/utils/serverDataModel.test.ts +51 -1
- package/src/utils/serverDataModel.ts +2 -26
- package/vite.config.ts +0 -5
- package/dist/assets/exporters-DZhLN0ux.js +0 -5957
- package/dist/assets/geometry-controller.worker-DQOSYqtw.js +0 -7
- package/dist/assets/geometry.worker-B62e03Ao.js +0 -1
- package/dist/assets/ifc-lite-Ch2T9pP9.js +0 -7
- package/dist/assets/ifc-lite_bg-D7O1WHgP.wasm +0 -0
- package/dist/assets/ifc-lite_bg-iH_07wf8.wasm +0 -0
- package/dist/assets/parser.worker-BW1IMUed.js +0 -182
- package/dist/assets/raw-CoIXstQ-.js +0 -1
- package/dist/assets/wasm-bridge-CT7mK9W0.js +0 -1
- package/dist/assets/workerHelpers-IEQDo8r3.js +0 -36
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
4
|
|
|
5
5
|
import { IfcParser, parseIfcx, type IfcDataStore, type PointCloudExtraction } from '@ifc-lite/parser';
|
|
6
|
+
import { WorkerParser } from '@ifc-lite/parser/browser';
|
|
6
7
|
import { GeometryProcessor, GeometryQuality, type CoordinateInfo, type DynamicBatchConfig, type GeometryResult, type MeshData, type PointCloudAsset } from '@ifc-lite/geometry';
|
|
7
8
|
import { loadGLBToMeshData } from '@ifc-lite/cache';
|
|
8
9
|
import type { SchemaVersion } from '../../store/types.js';
|
|
9
10
|
import { calculateMeshBounds, calculateStoreyHeights, createCoordinateInfo, normalizeColor } from '../../utils/localParsingUtils.js';
|
|
11
|
+
import { resolveDataStoreOrAbort } from './resolveDataStoreOrAbort.js';
|
|
10
12
|
|
|
11
13
|
type RgbaColor = [number, number, number, number];
|
|
12
14
|
|
|
@@ -217,6 +219,18 @@ export async function parseStepBufferViewerModel(options: StepBufferIngestOption
|
|
|
217
219
|
|
|
218
220
|
const parser = new IfcParser();
|
|
219
221
|
const wasmApi = geometryProcessor.getApi();
|
|
222
|
+
const canShareSource = WorkerParser.isSupported();
|
|
223
|
+
const sharedSource = canShareSource ? new SharedArrayBuffer(options.buffer.byteLength) : null;
|
|
224
|
+
if (sharedSource) {
|
|
225
|
+
new Uint8Array(sharedSource).set(new Uint8Array(options.buffer));
|
|
226
|
+
}
|
|
227
|
+
const geometryWillEmitEntityIndex =
|
|
228
|
+
sharedSource !== null
|
|
229
|
+
&& options.fileSizeMB >= 2
|
|
230
|
+
&& typeof Worker !== 'undefined'
|
|
231
|
+
&& typeof navigator !== 'undefined'
|
|
232
|
+
&& (navigator.hardwareConcurrency ?? 1) > 1;
|
|
233
|
+
let workerParser: WorkerParser | null = null;
|
|
220
234
|
const allMeshes: MeshData[] = [];
|
|
221
235
|
const cumulativeColorUpdates = new Map<number, RgbaColor>();
|
|
222
236
|
let finalCoordinateInfo: CoordinateInfo | null = null;
|
|
@@ -224,20 +238,40 @@ export async function parseStepBufferViewerModel(options: StepBufferIngestOption
|
|
|
224
238
|
let estimatedTotal = 0;
|
|
225
239
|
let capturedRtcOffset: { x: number; y: number; z: number } | null = null;
|
|
226
240
|
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
241
|
+
const handleSpatialReady = (partialStore: IfcDataStore) => {
|
|
242
|
+
if (options.shouldAbort?.()) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
options.onSpatialReady?.(normalizeDataStoreStoreys(partialStore));
|
|
246
|
+
};
|
|
247
|
+
const dataStorePromise = sharedSource
|
|
248
|
+
? (() => {
|
|
249
|
+
workerParser = new WorkerParser();
|
|
250
|
+
return workerParser.parseColumnar(sharedSource, {
|
|
251
|
+
waitForEntityIndex: geometryWillEmitEntityIndex,
|
|
252
|
+
onSpatialReady: handleSpatialReady,
|
|
253
|
+
}).catch((error) => {
|
|
254
|
+
console.warn('[viewerModelIngest] Parser worker failed, falling back to main-thread parse:', error);
|
|
255
|
+
return parser.parseColumnar(options.buffer, {
|
|
256
|
+
wasmApi: wasmApi ?? undefined,
|
|
257
|
+
onSpatialReady: handleSpatialReady,
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
})()
|
|
261
|
+
: parser.parseColumnar(options.buffer, {
|
|
262
|
+
wasmApi: wasmApi ?? undefined,
|
|
263
|
+
onSpatialReady: handleSpatialReady,
|
|
264
|
+
});
|
|
236
265
|
|
|
237
|
-
|
|
266
|
+
const geometryView = sharedSource ? new Uint8Array(sharedSource) : new Uint8Array(options.buffer);
|
|
267
|
+
for await (const event of geometryProcessor.processAdaptive(geometryView, {
|
|
238
268
|
sizeThreshold: 2 * 1024 * 1024,
|
|
239
269
|
batchSize: options.getDynamicBatchSize(options.fileSizeMB),
|
|
240
270
|
sharedRtcOffset: options.sharedRtcOffset,
|
|
271
|
+
existingSab: sharedSource ?? undefined,
|
|
272
|
+
onEntityIndex: (ids, starts, lengths) => {
|
|
273
|
+
workerParser?.setEntityIndex(ids, starts, lengths);
|
|
274
|
+
},
|
|
241
275
|
})) {
|
|
242
276
|
if (options.shouldAbort?.()) {
|
|
243
277
|
break;
|
|
@@ -282,7 +316,17 @@ export async function parseStepBufferViewerModel(options: StepBufferIngestOption
|
|
|
282
316
|
}
|
|
283
317
|
}
|
|
284
318
|
|
|
285
|
-
|
|
319
|
+
// If the load was cancelled, don't await dataStorePromise: a worker parse
|
|
320
|
+
// started with waitForEntityIndex blocks until the geometry pre-pass hands
|
|
321
|
+
// over the entity index, which never happens once the geometry loop has been
|
|
322
|
+
// aborted above. resolveDataStoreOrAbort terminates the worker and throws an
|
|
323
|
+
// AbortError instead of hanging here.
|
|
324
|
+
const dataStore = normalizeDataStoreStoreys(
|
|
325
|
+
await resolveDataStoreOrAbort(dataStorePromise, {
|
|
326
|
+
aborted: options.shouldAbort?.() ?? false,
|
|
327
|
+
terminate: () => workerParser?.terminate(),
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
286
330
|
if (!finalCoordinateInfo) {
|
|
287
331
|
finalCoordinateInfo = createCoordinateInfo(calculateMeshBounds(allMeshes).bounds);
|
|
288
332
|
}
|
package/src/hooks/useBCF.ts
CHANGED
|
@@ -13,13 +13,15 @@
|
|
|
13
13
|
|
|
14
14
|
import { useCallback, useRef } from 'react';
|
|
15
15
|
import { useViewerStore } from '@/store';
|
|
16
|
-
import type { BCFViewpoint } from '@ifc-lite/bcf';
|
|
16
|
+
import type { BCFTopic, BCFViewpoint } from '@ifc-lite/bcf';
|
|
17
17
|
import {
|
|
18
18
|
createViewpoint,
|
|
19
19
|
extractViewpointState,
|
|
20
|
+
computeMarkerPositions,
|
|
20
21
|
type ViewerCameraState,
|
|
21
22
|
type ViewerSectionPlane,
|
|
22
23
|
type ViewerBounds,
|
|
24
|
+
type OverlayBBox,
|
|
23
25
|
} from '@ifc-lite/bcf';
|
|
24
26
|
import type { Renderer } from '@ifc-lite/renderer';
|
|
25
27
|
import {
|
|
@@ -52,6 +54,10 @@ interface UseBCFResult {
|
|
|
52
54
|
createViewpointFromState: (options?: CreateViewpointOptions) => Promise<BCFViewpoint | null>;
|
|
53
55
|
/** Apply a viewpoint to the viewer */
|
|
54
56
|
applyViewpoint: (viewpoint: BCFViewpoint, animate?: boolean) => void;
|
|
57
|
+
/** Animate the camera to a BCF topic's 3D location (without changing selection/visibility) */
|
|
58
|
+
zoomToTopic: (topic: BCFTopic) => void;
|
|
59
|
+
/** Whether a topic has enough data to zoom to */
|
|
60
|
+
canZoomToTopic: (topic: BCFTopic) => boolean;
|
|
55
61
|
/** Capture a snapshot from the canvas */
|
|
56
62
|
captureSnapshot: () => Promise<string | null>;
|
|
57
63
|
/** Set the canvas ref for snapshot capture */
|
|
@@ -96,6 +102,21 @@ export function clearGlobalRefs(): void {
|
|
|
96
102
|
globalRendererRef = null;
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
/** Apply extracted BCF camera state without touching selection, visibility, or section plane. */
|
|
106
|
+
function applyCameraState(
|
|
107
|
+
renderer: Renderer,
|
|
108
|
+
camera: NonNullable<ReturnType<typeof extractViewpointState>['camera']>,
|
|
109
|
+
animate: boolean,
|
|
110
|
+
): void {
|
|
111
|
+
const rendererCamera = renderer.getCamera();
|
|
112
|
+
if (animate) {
|
|
113
|
+
rendererCamera.animateTo(camera.position, camera.target, 300);
|
|
114
|
+
} else {
|
|
115
|
+
rendererCamera.setPosition(camera.position.x, camera.position.y, camera.position.z);
|
|
116
|
+
rendererCamera.setTarget(camera.target.x, camera.target.y, camera.target.z);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
99
120
|
// ============================================================================
|
|
100
121
|
// Hook
|
|
101
122
|
// ============================================================================
|
|
@@ -351,6 +372,28 @@ export function useBCF(options: UseBCFOptions = {}): UseBCFResult {
|
|
|
351
372
|
]
|
|
352
373
|
);
|
|
353
374
|
|
|
375
|
+
/** Restore only the viewpoint camera (used by zoom-to-topic fallback). */
|
|
376
|
+
const applyViewpointCamera = useCallback(
|
|
377
|
+
(viewpoint: BCFViewpoint, animate = true) => {
|
|
378
|
+
const renderer = getRenderer();
|
|
379
|
+
if (!renderer) {
|
|
380
|
+
console.warn('[useBCF] Cannot apply viewpoint camera: no renderer');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const bounds = getBounds() ?? undefined;
|
|
385
|
+
const state = extractViewpointState(
|
|
386
|
+
viewpoint,
|
|
387
|
+
bounds,
|
|
388
|
+
renderer.getCamera().getDistance(),
|
|
389
|
+
);
|
|
390
|
+
if (state.camera) {
|
|
391
|
+
applyCameraState(renderer, state.camera, animate);
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
[getRenderer, getBounds],
|
|
395
|
+
);
|
|
396
|
+
|
|
354
397
|
/**
|
|
355
398
|
* Apply a viewpoint to the viewer
|
|
356
399
|
*/
|
|
@@ -372,22 +415,8 @@ export function useBCF(options: UseBCFOptions = {}): UseBCFResult {
|
|
|
372
415
|
);
|
|
373
416
|
const { camera, sectionPlane: viewpointSectionPlane } = state;
|
|
374
417
|
|
|
375
|
-
// Apply camera
|
|
376
418
|
if (camera) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
if (animate) {
|
|
380
|
-
// Animate to new position
|
|
381
|
-
rendererCamera.animateTo(
|
|
382
|
-
camera.position,
|
|
383
|
-
camera.target,
|
|
384
|
-
300 // 300ms animation
|
|
385
|
-
);
|
|
386
|
-
} else {
|
|
387
|
-
// Set immediately
|
|
388
|
-
rendererCamera.setPosition(camera.position.x, camera.position.y, camera.position.z);
|
|
389
|
-
rendererCamera.setTarget(camera.target.x, camera.target.y, camera.target.z);
|
|
390
|
-
}
|
|
419
|
+
applyCameraState(renderer, camera, animate);
|
|
391
420
|
}
|
|
392
421
|
|
|
393
422
|
// Apply section plane
|
|
@@ -482,9 +511,62 @@ export function useBCF(options: UseBCFOptions = {}): UseBCFResult {
|
|
|
482
511
|
]
|
|
483
512
|
);
|
|
484
513
|
|
|
514
|
+
const canZoomToTopic = useCallback((topic: BCFTopic): boolean => {
|
|
515
|
+
return topic.viewpoints.length > 0;
|
|
516
|
+
}, []);
|
|
517
|
+
|
|
518
|
+
const zoomToTopic = useCallback(
|
|
519
|
+
(topic: BCFTopic) => {
|
|
520
|
+
const renderer = getRenderer();
|
|
521
|
+
if (!renderer || topic.viewpoints.length === 0) return;
|
|
522
|
+
|
|
523
|
+
const boundsLookup = (ifcGuid: string): OverlayBBox | null => {
|
|
524
|
+
const result = globalIdToExpressId(ifcGuid);
|
|
525
|
+
if (!result) return null;
|
|
526
|
+
return renderer.getScene().getEntityBoundingBox(result.expressId);
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const markers = computeMarkerPositions([topic], boundsLookup, {
|
|
530
|
+
targetDistance: renderer.getCamera().getDistance(),
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
if (markers.length > 0) {
|
|
534
|
+
const marker = markers[0];
|
|
535
|
+
|
|
536
|
+
if (marker.positionSource === 'component') {
|
|
537
|
+
for (let i = topic.viewpoints.length - 1; i >= 0; i--) {
|
|
538
|
+
const vp = topic.viewpoints[i];
|
|
539
|
+
const guids = [
|
|
540
|
+
...(vp.components?.selection ?? []),
|
|
541
|
+
...(vp.components?.visibility?.exceptions ?? []),
|
|
542
|
+
];
|
|
543
|
+
for (const comp of guids) {
|
|
544
|
+
if (!comp.ifcGuid) continue;
|
|
545
|
+
const bbox = boundsLookup(comp.ifcGuid);
|
|
546
|
+
if (bbox) {
|
|
547
|
+
void renderer.getCamera().frameBounds(bbox.min, bbox.max);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const point = marker.connectorAnchor ?? marker.position;
|
|
555
|
+
void renderer.getCamera().framePoint(point);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Fallback: camera from latest viewpoint only — preserve selection/visibility
|
|
560
|
+
applyViewpointCamera(topic.viewpoints[topic.viewpoints.length - 1], true);
|
|
561
|
+
},
|
|
562
|
+
[applyViewpointCamera, getRenderer, globalIdToExpressId],
|
|
563
|
+
);
|
|
564
|
+
|
|
485
565
|
return {
|
|
486
566
|
createViewpointFromState,
|
|
487
567
|
applyViewpoint,
|
|
568
|
+
zoomToTopic,
|
|
569
|
+
canZoomToTopic,
|
|
488
570
|
captureSnapshot,
|
|
489
571
|
setCanvasRef,
|
|
490
572
|
setRendererRef,
|
|
@@ -32,6 +32,14 @@ export const AXIS_MAP: Record<'down' | 'front' | 'side', 'x' | 'y' | 'z'> = {
|
|
|
32
32
|
side: 'x',
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
// Depth of the slab IN FRONT of the section plane (in shifted-world
|
|
36
|
+
// metres) within which IFC annotation/grid primitives are kept. Beyond
|
|
37
|
+
// the slab they're culled — matches a typical plan-view "view depth"
|
|
38
|
+
// where dimensions for the next storey shouldn't bleed through. The
|
|
39
|
+
// shifted-bounds coordinate system the centroids and `position` both
|
|
40
|
+
// live in is already in metres (WASM applies `unit_scale` upstream).
|
|
41
|
+
export const ANNOTATION_VIEW_DEPTH = 1.2;
|
|
42
|
+
|
|
35
43
|
interface UseDrawingGenerationParams {
|
|
36
44
|
geometryResult: GeometryResult | null | undefined;
|
|
37
45
|
ifcDataStore: { source: Uint8Array } | null;
|
|
@@ -92,10 +100,28 @@ export function useDrawingGeneration({
|
|
|
92
100
|
// Track if this is a regeneration (vs initial generation)
|
|
93
101
|
const isRegeneratingRef = useRef(false);
|
|
94
102
|
|
|
103
|
+
// Symbolic lines carry the parent primitive's world-space centroid so the
|
|
104
|
+
// 2D Section filter below can cull them against the active cut plane —
|
|
105
|
+
// cardinal axis OR a face-picked custom plane. The drawing-2d package's
|
|
106
|
+
// DrawingLine has no per-line position slot; attaching the centroid as
|
|
107
|
+
// extra fields keeps the change local since the canvas ignores anything
|
|
108
|
+
// beyond DrawingLine's declared fields.
|
|
109
|
+
//
|
|
110
|
+
// Coordinate space matches the section cutter's input (shifted bounds):
|
|
111
|
+
// - worldX: read from the polyline's 2D x (already RTC-shifted by WASM)
|
|
112
|
+
// - worldZ: -(polyline 2D y) — WASM negates Z into the 2D y axis to
|
|
113
|
+
// match section-cut output handedness, so flip back here
|
|
114
|
+
// - worldY: from the WASM `worldY` accessor (vertical elevation)
|
|
115
|
+
type SymbolicDrawingLine = DrawingLine & {
|
|
116
|
+
worldX?: number;
|
|
117
|
+
worldY?: number;
|
|
118
|
+
worldZ?: number;
|
|
119
|
+
};
|
|
120
|
+
|
|
95
121
|
// Cache for symbolic representations - these don't change with section position
|
|
96
122
|
// Only re-parse when model or display options change
|
|
97
123
|
const symbolicCacheRef = useRef<{
|
|
98
|
-
lines:
|
|
124
|
+
lines: SymbolicDrawingLine[];
|
|
99
125
|
entities: Set<number>;
|
|
100
126
|
sourceId: string | null;
|
|
101
127
|
useSymbolic: boolean;
|
|
@@ -120,7 +146,7 @@ export function useDrawingGeneration({
|
|
|
120
146
|
|
|
121
147
|
// Parse symbolic representations if enabled (for hybrid mode)
|
|
122
148
|
// OPTIMIZATION: Cache symbolic data - it doesn't change with section position
|
|
123
|
-
let symbolicLines:
|
|
149
|
+
let symbolicLines: SymbolicDrawingLine[] = [];
|
|
124
150
|
let entitiesWithSymbols = new Set<number>();
|
|
125
151
|
|
|
126
152
|
// For multi-model: create cache key from model count and visible model IDs
|
|
@@ -167,6 +193,26 @@ export function useDrawingGeneration({
|
|
|
167
193
|
entitiesWithSymbols.add(poly.expressId);
|
|
168
194
|
const points = poly.points;
|
|
169
195
|
const pointCount = poly.pointCount;
|
|
196
|
+
// WASM exposes `worldY` on every symbolic primitive — the
|
|
197
|
+
// elevation of its parent placement (Z-up IFC, world-Y here).
|
|
198
|
+
// The .d.ts shipped with the @ifc-lite/wasm package lags
|
|
199
|
+
// behind the Rust source; read defensively so a stale build
|
|
200
|
+
// returns undefined instead of throwing.
|
|
201
|
+
const polyWorldY = (poly as unknown as { worldY?: number }).worldY;
|
|
202
|
+
// Centroid in shifted world coords — derived from the 2D
|
|
203
|
+
// points the WASM extractor already emits in section-cut
|
|
204
|
+
// space. point.x = world X (RTC-shifted); point.y =
|
|
205
|
+
// -world Z (negated to match cut-output handedness), so
|
|
206
|
+
// flip the sign back to recover world Z. Computed once
|
|
207
|
+
// per source polyline and shared across its segments.
|
|
208
|
+
let sumX = 0;
|
|
209
|
+
let sumY = 0;
|
|
210
|
+
for (let p = 0; p < pointCount; p++) {
|
|
211
|
+
sumX += points[p * 2];
|
|
212
|
+
sumY += points[p * 2 + 1];
|
|
213
|
+
}
|
|
214
|
+
const polyWorldX = pointCount > 0 ? sumX / pointCount : undefined;
|
|
215
|
+
const polyWorldZ = pointCount > 0 ? -sumY / pointCount : undefined;
|
|
170
216
|
|
|
171
217
|
for (let j = 0; j < pointCount - 1; j++) {
|
|
172
218
|
symbolicLines.push({
|
|
@@ -180,6 +226,9 @@ export function useDrawingGeneration({
|
|
|
180
226
|
ifcType: poly.ifcType,
|
|
181
227
|
modelIndex: symbolicModelIndex,
|
|
182
228
|
depth: 0,
|
|
229
|
+
worldX: polyWorldX,
|
|
230
|
+
worldY: polyWorldY,
|
|
231
|
+
worldZ: polyWorldZ,
|
|
183
232
|
});
|
|
184
233
|
}
|
|
185
234
|
|
|
@@ -195,6 +244,9 @@ export function useDrawingGeneration({
|
|
|
195
244
|
ifcType: poly.ifcType,
|
|
196
245
|
modelIndex: symbolicModelIndex,
|
|
197
246
|
depth: 0,
|
|
247
|
+
worldX: polyWorldX,
|
|
248
|
+
worldY: polyWorldY,
|
|
249
|
+
worldZ: polyWorldZ,
|
|
198
250
|
});
|
|
199
251
|
}
|
|
200
252
|
}
|
|
@@ -206,6 +258,12 @@ export function useDrawingGeneration({
|
|
|
206
258
|
|
|
207
259
|
entitiesWithSymbols.add(circle.expressId);
|
|
208
260
|
const numSegments = circle.isFullCircle ? 32 : 16;
|
|
261
|
+
const circleWorldY = (circle as unknown as { worldY?: number }).worldY;
|
|
262
|
+
// Centre in shifted world coords. circle.centerX is
|
|
263
|
+
// already RTC-shifted X; circle.centerY carries the
|
|
264
|
+
// negated Z (see polyline note above) — flip to recover.
|
|
265
|
+
const circleWorldX = circle.centerX;
|
|
266
|
+
const circleWorldZ = -circle.centerY;
|
|
209
267
|
|
|
210
268
|
for (let j = 0; j < numSegments; j++) {
|
|
211
269
|
const t1 = j / numSegments;
|
|
@@ -230,6 +288,9 @@ export function useDrawingGeneration({
|
|
|
230
288
|
ifcType: circle.ifcType,
|
|
231
289
|
modelIndex: symbolicModelIndex,
|
|
232
290
|
depth: 0,
|
|
291
|
+
worldX: circleWorldX,
|
|
292
|
+
worldY: circleWorldY,
|
|
293
|
+
worldZ: circleWorldZ,
|
|
233
294
|
});
|
|
234
295
|
}
|
|
235
296
|
}
|
|
@@ -375,9 +436,94 @@ export function useDrawingGeneration({
|
|
|
375
436
|
}
|
|
376
437
|
}
|
|
377
438
|
|
|
439
|
+
// When the user toggles `sectionPlane.flipped` on a cardinal axis,
|
|
440
|
+
// the cutter negates the 2D U axis (see `projectTo2D` in
|
|
441
|
+
// @ifc-lite/drawing-2d/math.ts and `data[6] = flipU` in the GPU
|
|
442
|
+
// cutter). Symbolic primitives come out of WASM in the cutter's
|
|
443
|
+
// UNFLIPPED basis — for the plan ('y') case `(line.x = worldX − rtc,
|
|
444
|
+
// line.y = −worldY + rtc)` — so on a flipped section the cut
|
|
445
|
+
// polygons land at −X while the symbolic lines stay at +X. The
|
|
446
|
+
// result the user reported: annotations sitting NEXT TO the model
|
|
447
|
+
// as if they were mirrored across the model's centre, instead of
|
|
448
|
+
// staying with the cut. Mirror symbolic X here to match the cutter
|
|
449
|
+
// for cardinal flipped sections. Custom face-pick planes use
|
|
450
|
+
// `projectTo2DBasis` (no U flip), so leave them untouched —
|
|
451
|
+
// symbolic alignment on an arbitrary basis is a separate problem
|
|
452
|
+
// and out of scope for this fix.
|
|
453
|
+
const mirrorSymbolicX = sectionPlane.flipped && !sectionPlane.custom;
|
|
454
|
+
const orientedSymbolicLines: SymbolicDrawingLine[] = mirrorSymbolicX
|
|
455
|
+
? symbolicLines.map((line) => ({
|
|
456
|
+
...line,
|
|
457
|
+
line: {
|
|
458
|
+
start: { x: -line.line.start.x, y: line.line.start.y },
|
|
459
|
+
end: { x: -line.line.end.x, y: line.line.end.y },
|
|
460
|
+
},
|
|
461
|
+
}))
|
|
462
|
+
: symbolicLines;
|
|
463
|
+
|
|
464
|
+
// Cull annotations to a thin view-depth slab IN FRONT of the cut.
|
|
465
|
+
//
|
|
466
|
+
// IfcAnnotation / IfcGridAxis polylines (dimensions, room tags, grid
|
|
467
|
+
// bubbles) live at a single elevation but have no body geometry —
|
|
468
|
+
// the `cutEntityIds.has(line.entityId)` filter below never matches
|
|
469
|
+
// them, so without this they render regardless of where the
|
|
470
|
+
// section sits.
|
|
471
|
+
//
|
|
472
|
+
// Reduce every cut mode (cardinal X/Y/Z + face-pick custom plane)
|
|
473
|
+
// to a single half-space test against a unit normal + signed
|
|
474
|
+
// distance. For cardinal axes the normal is the basis vector and
|
|
475
|
+
// distance is `position` (already in shifted-bounds coords, the
|
|
476
|
+
// same space the symbolic centroids land in). For custom planes
|
|
477
|
+
// the WASM cutter already uses `normal`/`distance` verbatim, so
|
|
478
|
+
// re-use both here for consistency with the cap.
|
|
479
|
+
//
|
|
480
|
+
// The kept window is `−ANNOTATION_VIEW_DEPTH ≤ signedDist ≤ 0` on
|
|
481
|
+
// the −normal side — the side BELOW a down-looking camera, where
|
|
482
|
+
// IFC dimensions live (authored at the storey's floor elevation,
|
|
483
|
+
// not at the cut height). This DIVERGES from
|
|
484
|
+
// `EdgeExtractor.filterEdgesByDepth`, which projects above the
|
|
485
|
+
// cut: annotations and projection edges are naturally on opposite
|
|
486
|
+
// sides of the cut plane. Flipped sections look at the same world
|
|
487
|
+
// from the opposite side, so the slab mirrors to
|
|
488
|
+
// `0 ≤ signedDist ≤ ANNOTATION_VIEW_DEPTH`.
|
|
489
|
+
//
|
|
490
|
+
// Anything on the wrong side of the cut, or farther than the view
|
|
491
|
+
// depth on the right side, is dropped — without the upper bound,
|
|
492
|
+
// dimensions from every storey beyond the cut stacked on top of
|
|
493
|
+
// each other because the half-space alone is unbounded along the
|
|
494
|
+
// camera axis.
|
|
495
|
+
//
|
|
496
|
+
// Annotations missing a recoverable centroid (older WASM build,
|
|
497
|
+
// or a degenerate polyline) are kept — over-rendering is preferable
|
|
498
|
+
// to silently dropping authored dimensions when the runtime can't
|
|
499
|
+
// classify them.
|
|
500
|
+
const cullNormal: [number, number, number] = sectionPlane.custom
|
|
501
|
+
? sectionPlane.custom.normal
|
|
502
|
+
: axis === 'x' ? [1, 0, 0]
|
|
503
|
+
: axis === 'y' ? [0, 1, 0]
|
|
504
|
+
: [0, 0, 1];
|
|
505
|
+
const cullDistance = sectionPlane.custom ? sectionPlane.custom.distance : position;
|
|
506
|
+
const annotationCulled = orientedSymbolicLines.filter((line) => {
|
|
507
|
+
const isAnnotationLike = line.ifcType === 'IfcAnnotation' || line.ifcType === 'IfcGridAxis';
|
|
508
|
+
if (!isAnnotationLike) return true;
|
|
509
|
+
const wx = line.worldX;
|
|
510
|
+
const wy = line.worldY;
|
|
511
|
+
const wz = line.worldZ;
|
|
512
|
+
if (wx === undefined || wy === undefined || wz === undefined) return true;
|
|
513
|
+
const signedDist =
|
|
514
|
+
wx * cullNormal[0] +
|
|
515
|
+
wy * cullNormal[1] +
|
|
516
|
+
wz * cullNormal[2] -
|
|
517
|
+
cullDistance;
|
|
518
|
+
if (sectionPlane.flipped) {
|
|
519
|
+
return signedDist >= 0 && signedDist <= ANNOTATION_VIEW_DEPTH;
|
|
520
|
+
}
|
|
521
|
+
return signedDist <= 0 && signedDist >= -ANNOTATION_VIEW_DEPTH;
|
|
522
|
+
});
|
|
523
|
+
|
|
378
524
|
// Only include symbolic lines for entities that are ACTUALLY being cut
|
|
379
525
|
// This filters out symbols from other floors/levels not intersected by the section plane
|
|
380
|
-
const relevantSymbolicLines =
|
|
526
|
+
const relevantSymbolicLines = annotationCulled.filter(line =>
|
|
381
527
|
line.entityId !== undefined && cutEntityIds.has(line.entityId)
|
|
382
528
|
);
|
|
383
529
|
|
package/src/hooks/useIfcCache.ts
CHANGED
|
@@ -13,10 +13,11 @@ import { useCallback } from 'react';
|
|
|
13
13
|
import {
|
|
14
14
|
BinaryCacheWriter,
|
|
15
15
|
BinaryCacheReader,
|
|
16
|
+
type CachedEntityIndexColumns,
|
|
16
17
|
type IfcDataStore as CacheDataStore,
|
|
17
18
|
type GeometryData,
|
|
18
19
|
} from '@ifc-lite/cache';
|
|
19
|
-
import { SpatialHierarchyBuilder, StepTokenizer, CompactEntityIndexBuilder, extractLengthUnitScale, type IfcDataStore } from '@ifc-lite/parser';
|
|
20
|
+
import { SpatialHierarchyBuilder, StepTokenizer, CompactEntityIndex, CompactEntityIndexBuilder, extractLengthUnitScale, type IfcDataStore } from '@ifc-lite/parser';
|
|
20
21
|
import { buildSpatialIndexGuarded } from '../utils/loadingUtils.js';
|
|
21
22
|
import type { MeshData } from '@ifc-lite/geometry';
|
|
22
23
|
|
|
@@ -30,6 +31,27 @@ import { calculateStoreyHeights } from '../utils/localParsingUtils.js';
|
|
|
30
31
|
export type { CacheResult } from '../services/cacheService.js';
|
|
31
32
|
export { getCached, setCached, deleteCached } from '../services/cacheService.js';
|
|
32
33
|
|
|
34
|
+
function buildEntityIndexFromCachedColumns(columns: CachedEntityIndexColumns): IfcDataStore['entityIndex'] {
|
|
35
|
+
const byId = new CompactEntityIndex(
|
|
36
|
+
columns.ids,
|
|
37
|
+
columns.byteOffsets,
|
|
38
|
+
columns.byteLengths,
|
|
39
|
+
columns.typeIndices,
|
|
40
|
+
columns.typeNames,
|
|
41
|
+
);
|
|
42
|
+
const byType = new Map<string, number[]>();
|
|
43
|
+
for (let i = 0; i < columns.ids.length; i++) {
|
|
44
|
+
const type = columns.typeNames[columns.typeIndices[i]];
|
|
45
|
+
let ids = byType.get(type);
|
|
46
|
+
if (!ids) {
|
|
47
|
+
ids = [];
|
|
48
|
+
byType.set(type, ids);
|
|
49
|
+
}
|
|
50
|
+
ids.push(columns.ids[i]);
|
|
51
|
+
}
|
|
52
|
+
return { byId, byType };
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
// ============================================================================
|
|
34
56
|
// Types
|
|
35
57
|
// ============================================================================
|
|
@@ -107,25 +129,28 @@ export function useIfcCache() {
|
|
|
107
129
|
if (cacheResult.sourceBuffer) {
|
|
108
130
|
dataStore.source = new Uint8Array(cacheResult.sourceBuffer);
|
|
109
131
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
typeList =
|
|
123
|
-
|
|
132
|
+
if (result.entityIndex) {
|
|
133
|
+
dataStore.entityIndex = buildEntityIndexFromCachedColumns(result.entityIndex);
|
|
134
|
+
} else {
|
|
135
|
+
// Backward compatibility for v3 caches: rebuild byte offsets from the
|
|
136
|
+
// source once, then future v4 writes persist this section.
|
|
137
|
+
const tokenizer = new StepTokenizer(dataStore.source);
|
|
138
|
+
const estimatedCount = dataStore.entities?.count ?? 100_000;
|
|
139
|
+
const indexBuilder = new CompactEntityIndexBuilder(estimatedCount);
|
|
140
|
+
const byType = new Map<string, number[]>();
|
|
141
|
+
|
|
142
|
+
for (const ref of tokenizer.scanEntitiesFast()) {
|
|
143
|
+
indexBuilder.add(ref.expressId, ref.type, ref.offset, ref.length);
|
|
144
|
+
let typeList = byType.get(ref.type);
|
|
145
|
+
if (!typeList) {
|
|
146
|
+
typeList = [];
|
|
147
|
+
byType.set(ref.type, typeList);
|
|
148
|
+
}
|
|
149
|
+
typeList.push(ref.expressId);
|
|
124
150
|
}
|
|
125
|
-
|
|
151
|
+
const compactByIdIndex = indexBuilder.build();
|
|
152
|
+
dataStore.entityIndex = { byId: compactByIdIndex, byType };
|
|
126
153
|
}
|
|
127
|
-
const compactByIdIndex = indexBuilder.build();
|
|
128
|
-
dataStore.entityIndex = { byId: compactByIdIndex, byType };
|
|
129
154
|
|
|
130
155
|
// Rebuild on-demand maps from relationships
|
|
131
156
|
// Pass entityIndex which contains ALL entity types including IfcPropertySet/IfcElementQuantity
|
|
@@ -254,6 +279,7 @@ export function useIfcCache() {
|
|
|
254
279
|
quantities: dataStore.quantities,
|
|
255
280
|
relationships: dataStore.relationships,
|
|
256
281
|
spatialHierarchy: dataStore.spatialHierarchy,
|
|
282
|
+
entityIndex: dataStore.entityIndex,
|
|
257
283
|
};
|
|
258
284
|
|
|
259
285
|
console.log('[useIfcCache] Writing cache buffer...');
|
|
@@ -1905,7 +1905,7 @@ export function useIfcLoader() {
|
|
|
1905
1905
|
// risking corruption.
|
|
1906
1906
|
const parserWasmApi = isNativeFileHandle(file) ? undefined : geometryProcessor.getApi();
|
|
1907
1907
|
return new IfcParser().parseColumnar(buffer, {
|
|
1908
|
-
wasmApi: parserWasmApi,
|
|
1908
|
+
wasmApi: parserWasmApi ?? undefined,
|
|
1909
1909
|
onSpatialReady: onPartialDataStore,
|
|
1910
1910
|
});
|
|
1911
1911
|
};
|
|
@@ -2032,34 +2032,12 @@ export function useIfcLoader() {
|
|
|
2032
2032
|
// When the parser worker is in use, hand the geometry workers the
|
|
2033
2033
|
// same SAB so we don't pay the file-bytes copy twice.
|
|
2034
2034
|
const geometryView = sharedSource ? new Uint8Array(sharedSource) : new Uint8Array(buffer);
|
|
2035
|
-
// Phase 2 of single-controller-rayon-design.md — opt-in via
|
|
2036
|
-
// localStorage so we can A/B compare against the N-worker
|
|
2037
|
-
// baseline without rolling out for everyone. Users (and the
|
|
2038
|
-
// benchmark harness) flip this with:
|
|
2039
|
-
// localStorage.setItem('ifc-lite:single-controller', '1')
|
|
2040
|
-
// and reload. Set to anything else (or unset) for the legacy
|
|
2041
|
-
// N-worker path. Safe: if the threaded WASM bundle fails to
|
|
2042
|
-
// load (no COI, Safari, etc.) the controller worker falls back
|
|
2043
|
-
// to per-task serial execution within the controller itself
|
|
2044
|
-
// (par_iter without an initialized pool).
|
|
2045
|
-
const useSingleController = (() => {
|
|
2046
|
-
try {
|
|
2047
|
-
return typeof localStorage !== 'undefined'
|
|
2048
|
-
&& localStorage.getItem('ifc-lite:single-controller') === '1';
|
|
2049
|
-
} catch {
|
|
2050
|
-
return false;
|
|
2051
|
-
}
|
|
2052
|
-
})();
|
|
2053
|
-
if (useSingleController) {
|
|
2054
|
-
console.log('[useIfc] single-controller path enabled (Phase 2)');
|
|
2055
|
-
}
|
|
2056
2035
|
const geometryEvents = shouldUseDesktopStableWasmGeometry
|
|
2057
2036
|
? geometryProcessor.processStreaming(geometryView, undefined, dynamicBatchConfig)
|
|
2058
2037
|
: geometryProcessor.processAdaptive(geometryView, {
|
|
2059
2038
|
sizeThreshold: 2 * 1024 * 1024, // 2MB threshold
|
|
2060
2039
|
batchSize: dynamicBatchConfig, // Dynamic batches: small first, then large
|
|
2061
2040
|
existingSab: sharedSource ?? undefined,
|
|
2062
|
-
useSingleController,
|
|
2063
2041
|
// Hand the streaming pre-pass's entity index to the parser
|
|
2064
2042
|
// worker so it skips a duplicate ~10 s WASM scan. Safe even
|
|
2065
2043
|
// when the parser falls back to main-thread (instance is
|