@ifc-lite/viewer 1.6.1 → 1.8.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/CHANGELOG.md +106 -0
- package/dist/assets/{Arrow.dom-Be1tgmo6.js → Arrow.dom-CwcRxist.js} +1 -1
- package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
- package/dist/assets/index-7WoQ-qVC.css +1 -0
- package/dist/assets/{index-D1Du89Pa.js → index-BSANf7-H.js} +44948 -31410
- package/dist/assets/{native-bridge-A6zNnTfi.js → native-bridge-5LbrYh3R.js} +1 -1
- package/dist/assets/{wasm-bridge-DkRhgSvE.js → wasm-bridge-CgpLtj1h.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +18 -15
- package/src/components/viewer/BCFPanel.tsx +7 -789
- package/src/components/viewer/Drawing2DCanvas.tsx +1411 -0
- package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
- package/src/components/viewer/EntityContextMenu.tsx +47 -20
- package/src/components/viewer/ExportDialog.tsx +166 -17
- package/src/components/viewer/HierarchyPanel.tsx +113 -843
- package/src/components/viewer/IDSExportDialog.tsx +281 -0
- package/src/components/viewer/IDSPanel.tsx +126 -17
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
- package/src/components/viewer/LensPanel.tsx +1366 -0
- package/src/components/viewer/MainToolbar.tsx +237 -37
- package/src/components/viewer/PropertiesPanel.tsx +171 -652
- package/src/components/viewer/PropertyEditor.tsx +866 -77
- package/src/components/viewer/Section2DPanel.tsx +329 -2661
- package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
- package/src/components/viewer/ToolOverlays.tsx +3 -1097
- package/src/components/viewer/ViewerLayout.tsx +132 -45
- package/src/components/viewer/Viewport.tsx +290 -1678
- package/src/components/viewer/ViewportContainer.tsx +13 -3
- package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
- package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
- package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
- package/src/components/viewer/hierarchy/types.ts +54 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
- package/src/components/viewer/lists/ListBuilder.tsx +486 -0
- package/src/components/viewer/lists/ListPanel.tsx +540 -0
- package/src/components/viewer/lists/ListResultsTable.tsx +227 -0
- package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
- package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
- package/src/components/viewer/properties/DocumentCard.tsx +89 -0
- package/src/components/viewer/properties/MaterialCard.tsx +201 -0
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
- package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
- package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
- package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
- package/src/components/viewer/properties/encodingUtils.ts +29 -0
- package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
- package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
- package/src/components/viewer/tools/SectionPanel.tsx +183 -0
- package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
- package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
- package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
- package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
- package/src/components/viewer/tools/computePolygonArea.ts +72 -0
- package/src/components/viewer/tools/formatDistance.ts +18 -0
- package/src/components/viewer/tools/sectionConstants.ts +14 -0
- package/src/components/viewer/useAnimationLoop.ts +166 -0
- package/src/components/viewer/useGeometryStreaming.ts +406 -0
- package/src/components/viewer/useKeyboardControls.ts +221 -0
- package/src/components/viewer/useMouseControls.ts +1009 -0
- package/src/components/viewer/useRenderUpdates.ts +165 -0
- package/src/components/viewer/useTouchControls.ts +245 -0
- package/src/hooks/ids/idsColorSystem.ts +125 -0
- package/src/hooks/ids/idsDataAccessor.ts +237 -0
- package/src/hooks/ids/idsExportService.ts +444 -0
- package/src/hooks/useAnnotation2D.ts +551 -0
- package/src/hooks/useBCF.ts +7 -0
- package/src/hooks/useDrawingExport.ts +709 -0
- package/src/hooks/useDrawingGeneration.ts +627 -0
- package/src/hooks/useFloorplanView.ts +108 -0
- package/src/hooks/useIDS.ts +270 -463
- package/src/hooks/useIfc.ts +26 -1628
- package/src/hooks/useIfcFederation.ts +803 -0
- package/src/hooks/useIfcLoader.ts +508 -0
- package/src/hooks/useIfcServer.ts +465 -0
- package/src/hooks/useKeyboardShortcuts.ts +114 -15
- package/src/hooks/useLens.ts +113 -0
- package/src/hooks/useLensDiscovery.ts +46 -0
- package/src/hooks/useMeasure2D.ts +365 -0
- package/src/hooks/useModelSelection.ts +5 -22
- package/src/hooks/useViewControls.ts +218 -0
- package/src/index.css +7 -1
- package/src/lib/ifc4-pset-definitions.test.ts +161 -0
- package/src/lib/ifc4-pset-definitions.ts +621 -0
- package/src/lib/ifc4-qto-definitions.ts +315 -0
- package/src/lib/lens/adapter.ts +264 -0
- package/src/lib/lens/index.ts +5 -0
- package/src/lib/lists/adapter.ts +69 -0
- package/src/lib/lists/columnToAutoColor.ts +33 -0
- package/src/lib/lists/index.ts +28 -0
- package/src/lib/lists/persistence.ts +64 -0
- package/src/services/fs-cache.ts +1 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/index.ts +52 -3
- package/src/store/resolveEntityRef.ts +44 -0
- package/src/store/slices/cameraSlice.ts +14 -1
- package/src/store/slices/dataSlice.ts +14 -1
- package/src/store/slices/drawing2DSlice.ts +321 -0
- package/src/store/slices/lensSlice.ts +226 -0
- package/src/store/slices/listSlice.ts +74 -0
- package/src/store/slices/pinboardSlice.ts +247 -0
- package/src/store/types.ts +5 -0
- package/src/store.ts +3 -0
- package/src/utils/ifcConfig.ts +16 -3
- package/src/utils/serverDataModel.ts +64 -101
- package/src/vite-env.d.ts +3 -0
- package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
- package/dist/assets/index-v3mcCUPN.css +0 -1
|
@@ -0,0 +1,465 @@
|
|
|
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
|
+
* Hook for server-side IFC parsing
|
|
7
|
+
* Manages ServerClient instance, server reachability checking,
|
|
8
|
+
* and streaming/Parquet/JSON parsing paths
|
|
9
|
+
*
|
|
10
|
+
* Extracted from useIfc.ts for better separation of concerns
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useCallback } from 'react';
|
|
14
|
+
import { useViewerStore } from '../store.js';
|
|
15
|
+
import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
|
|
16
|
+
import {
|
|
17
|
+
IfcServerClient,
|
|
18
|
+
decodeDataModel,
|
|
19
|
+
type ParquetBatch,
|
|
20
|
+
type DataModel,
|
|
21
|
+
type ParquetParseResponse,
|
|
22
|
+
type ParquetStreamResult,
|
|
23
|
+
type ParseResponse,
|
|
24
|
+
type ModelMetadata,
|
|
25
|
+
type ProcessingStats,
|
|
26
|
+
type MeshData as ServerMeshData,
|
|
27
|
+
} from '@ifc-lite/server-client';
|
|
28
|
+
|
|
29
|
+
import { SERVER_URL } from '../utils/ifcConfig.js';
|
|
30
|
+
import {
|
|
31
|
+
createEmptyBounds,
|
|
32
|
+
updateBoundsFromPositions,
|
|
33
|
+
calculateMeshBounds,
|
|
34
|
+
createCoordinateInfo,
|
|
35
|
+
getServerStreamIntervalMs,
|
|
36
|
+
} from '../utils/localParsingUtils.js';
|
|
37
|
+
|
|
38
|
+
// Server data model conversion
|
|
39
|
+
import { convertServerDataModel, type ServerParseResult } from '../utils/serverDataModel.js';
|
|
40
|
+
|
|
41
|
+
/** Convert server mesh data (snake_case) to viewer format (camelCase) */
|
|
42
|
+
function convertServerMesh(m: ServerMeshData): MeshData {
|
|
43
|
+
return {
|
|
44
|
+
expressId: m.express_id,
|
|
45
|
+
positions: new Float32Array(m.positions),
|
|
46
|
+
indices: new Uint32Array(m.indices),
|
|
47
|
+
normals: m.normals ? new Float32Array(m.normals) : new Float32Array(0),
|
|
48
|
+
color: m.color,
|
|
49
|
+
ifcType: m.ifc_type,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Server parse result type - union of streaming and non-streaming responses */
|
|
54
|
+
type ServerParseResultType = ParquetParseResponse | ParquetStreamResult | ParseResponse;
|
|
55
|
+
|
|
56
|
+
// Module-level server availability cache - avoids repeated failed connection attempts
|
|
57
|
+
let serverAvailabilityCache: { available: boolean; checkedAt: number } | null = null;
|
|
58
|
+
const SERVER_CHECK_CACHE_MS = 30000; // Re-check server availability every 30 seconds
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check if server URL is reachable from current origin
|
|
62
|
+
* Returns false immediately if localhost server from non-localhost origin (would cause CORS)
|
|
63
|
+
*/
|
|
64
|
+
function isServerReachable(serverUrl: string): boolean {
|
|
65
|
+
try {
|
|
66
|
+
const server = new URL(serverUrl);
|
|
67
|
+
const isServerLocalhost = server.hostname === 'localhost' || server.hostname === '127.0.0.1';
|
|
68
|
+
|
|
69
|
+
// In browser, check if we're on localhost
|
|
70
|
+
if (typeof window !== 'undefined') {
|
|
71
|
+
const isClientLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
|
72
|
+
|
|
73
|
+
// Skip localhost server when running from remote origin (avoids CORS error in console)
|
|
74
|
+
if (isServerLocalhost && !isClientLocalhost) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Silently check if server is available (no console logging on failure)
|
|
86
|
+
* Returns cached result if recently checked
|
|
87
|
+
*/
|
|
88
|
+
async function isServerAvailable(serverUrl: string, client: IfcServerClient): Promise<boolean> {
|
|
89
|
+
// First check if server is even reachable (prevents CORS errors)
|
|
90
|
+
if (!isServerReachable(serverUrl)) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
|
|
96
|
+
// Use cached result if recent
|
|
97
|
+
if (serverAvailabilityCache && (now - serverAvailabilityCache.checkedAt) < SERVER_CHECK_CACHE_MS) {
|
|
98
|
+
return serverAvailabilityCache.available;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Perform silent health check
|
|
102
|
+
try {
|
|
103
|
+
await client.health();
|
|
104
|
+
serverAvailabilityCache = { available: true, checkedAt: now };
|
|
105
|
+
return true;
|
|
106
|
+
} catch {
|
|
107
|
+
// Silent failure - don't log network errors for unavailable server
|
|
108
|
+
serverAvailabilityCache = { available: false, checkedAt: now };
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Hook for server-side IFC file parsing
|
|
115
|
+
* Handles server reachability, streaming/Parquet/JSON parsing paths,
|
|
116
|
+
* and ServerClient lifecycle
|
|
117
|
+
*/
|
|
118
|
+
export function useIfcServer() {
|
|
119
|
+
/**
|
|
120
|
+
* Load from server - uses server-side PARALLEL parsing for maximum speed
|
|
121
|
+
* Uses full parse endpoint (not streaming) for all-at-once parallel processing
|
|
122
|
+
*
|
|
123
|
+
* Store actions are retrieved via getState() inside the callback to avoid
|
|
124
|
+
* subscribing the hook to the entire store (which would cause unnecessary re-renders).
|
|
125
|
+
*/
|
|
126
|
+
const loadFromServer = useCallback(async (
|
|
127
|
+
file: File,
|
|
128
|
+
buffer: ArrayBuffer
|
|
129
|
+
): Promise<boolean> => {
|
|
130
|
+
const { setProgress, setIfcDataStore, setGeometryResult } = useViewerStore.getState();
|
|
131
|
+
try {
|
|
132
|
+
const serverStart = performance.now();
|
|
133
|
+
setProgress({ phase: 'Connecting to server', percent: 5 });
|
|
134
|
+
|
|
135
|
+
const client = new IfcServerClient({ baseUrl: SERVER_URL });
|
|
136
|
+
|
|
137
|
+
// Silent server availability check (cached, no error logging)
|
|
138
|
+
const serverAvailable = await isServerAvailable(SERVER_URL, client);
|
|
139
|
+
if (!serverAvailable) {
|
|
140
|
+
return false; // Silently fall back - caller handles logging
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
setProgress({ phase: 'Processing on server (parallel)', percent: 15 });
|
|
144
|
+
|
|
145
|
+
// Check if Parquet is supported (requires parquet-wasm)
|
|
146
|
+
const parquetSupported = await client.isParquetSupported();
|
|
147
|
+
|
|
148
|
+
let allMeshes: MeshData[];
|
|
149
|
+
let result: ServerParseResultType;
|
|
150
|
+
let parseTime: number;
|
|
151
|
+
let convertTime: number;
|
|
152
|
+
|
|
153
|
+
// Use streaming for large files (>150MB) for progressive rendering
|
|
154
|
+
// Smaller files use non-streaming path (faster - avoids ~1.1s background re-processing overhead)
|
|
155
|
+
// Streaming overhead: ~67 batch serializations + background re-processing (~1100ms)
|
|
156
|
+
// Non-streaming: single serialization (~218ms for 60k meshes)
|
|
157
|
+
// Threshold chosen to balance UX (progressive rendering) vs performance (overhead)
|
|
158
|
+
const fileSizeMB = buffer.byteLength / (1024 * 1024);
|
|
159
|
+
const USE_STREAMING_THRESHOLD_MB = 150;
|
|
160
|
+
|
|
161
|
+
if (parquetSupported && fileSizeMB > USE_STREAMING_THRESHOLD_MB) {
|
|
162
|
+
// STREAMING PATH - for large files, render progressively
|
|
163
|
+
console.log(`[useIfc] Using STREAMING endpoint for large file (${fileSizeMB.toFixed(1)}MB)`);
|
|
164
|
+
|
|
165
|
+
allMeshes = [];
|
|
166
|
+
let totalVertices = 0;
|
|
167
|
+
let totalTriangles = 0;
|
|
168
|
+
let cacheKey = '';
|
|
169
|
+
let streamMetadata: ModelMetadata | null = null;
|
|
170
|
+
let streamStats: ProcessingStats | null = null;
|
|
171
|
+
let batchCount = 0;
|
|
172
|
+
|
|
173
|
+
// Progressive bounds calculation
|
|
174
|
+
const bounds = createEmptyBounds();
|
|
175
|
+
|
|
176
|
+
const parseStart = performance.now();
|
|
177
|
+
|
|
178
|
+
// Throttle server streaming updates - large files get less frequent UI updates
|
|
179
|
+
let lastServerStreamRenderTime = 0;
|
|
180
|
+
const SERVER_STREAM_INTERVAL_MS = getServerStreamIntervalMs(fileSizeMB);
|
|
181
|
+
|
|
182
|
+
// Use streaming endpoint with batch callback
|
|
183
|
+
const streamResult = await client.parseParquetStream(file, (batch: ParquetBatch) => {
|
|
184
|
+
batchCount++;
|
|
185
|
+
|
|
186
|
+
// Convert batch meshes to viewer format (snake_case to camelCase, number[] to TypedArray)
|
|
187
|
+
const batchMeshes: MeshData[] = batch.meshes.map(convertServerMesh);
|
|
188
|
+
|
|
189
|
+
// Update bounds incrementally
|
|
190
|
+
for (const mesh of batchMeshes) {
|
|
191
|
+
updateBoundsFromPositions(bounds, mesh.positions);
|
|
192
|
+
totalVertices += mesh.positions.length / 3;
|
|
193
|
+
totalTriangles += mesh.indices.length / 3;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Add to collection (use loop to avoid stack overflow with large batches)
|
|
197
|
+
for (let i = 0; i < batchMeshes.length; i++) allMeshes.push(batchMeshes[i]);
|
|
198
|
+
|
|
199
|
+
// THROTTLED PROGRESSIVE RENDERING: Update UI at controlled rate
|
|
200
|
+
// First batch renders immediately, subsequent batches throttled
|
|
201
|
+
const now = performance.now();
|
|
202
|
+
const shouldRender = batchCount === 1 || (now - lastServerStreamRenderTime >= SERVER_STREAM_INTERVAL_MS);
|
|
203
|
+
|
|
204
|
+
if (shouldRender) {
|
|
205
|
+
lastServerStreamRenderTime = now;
|
|
206
|
+
|
|
207
|
+
// Update progress
|
|
208
|
+
setProgress({
|
|
209
|
+
phase: `Streaming batch ${batchCount}`,
|
|
210
|
+
percent: Math.min(15 + (batchCount * 5), 85)
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// PROGRESSIVE RENDERING: Set geometry after each batch
|
|
214
|
+
// This allows the user to see geometry appearing progressively
|
|
215
|
+
const coordinateInfo = {
|
|
216
|
+
originShift: { x: 0, y: 0, z: 0 },
|
|
217
|
+
originalBounds: bounds,
|
|
218
|
+
shiftedBounds: bounds,
|
|
219
|
+
hasLargeCoordinates: false,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
setGeometryResult({
|
|
223
|
+
meshes: [...allMeshes], // Clone to trigger re-render
|
|
224
|
+
totalVertices,
|
|
225
|
+
totalTriangles,
|
|
226
|
+
coordinateInfo,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
parseTime = performance.now() - parseStart;
|
|
232
|
+
cacheKey = streamResult.cache_key;
|
|
233
|
+
streamMetadata = streamResult.metadata;
|
|
234
|
+
streamStats = streamResult.stats;
|
|
235
|
+
|
|
236
|
+
console.log(`[useIfc] Streaming complete in ${parseTime.toFixed(0)}ms`);
|
|
237
|
+
console.log(` ${batchCount} batches, ${allMeshes.length} meshes`);
|
|
238
|
+
console.log(` Cache key: ${cacheKey}`);
|
|
239
|
+
|
|
240
|
+
// Build final result object for data model fetching
|
|
241
|
+
// Note: meshes field is omitted - allMeshes is passed separately to convertServerDataModel
|
|
242
|
+
result = {
|
|
243
|
+
cache_key: cacheKey,
|
|
244
|
+
metadata: streamMetadata,
|
|
245
|
+
stats: streamStats,
|
|
246
|
+
} as ParquetStreamResult;
|
|
247
|
+
convertTime = 0; // Already converted inline
|
|
248
|
+
|
|
249
|
+
// Final geometry set with complete bounds
|
|
250
|
+
// Server already applies RTC shift to mesh positions, so bounds are shifted
|
|
251
|
+
// Reconstruct originalBounds by adding originShift back to shifted bounds
|
|
252
|
+
const originShift = streamMetadata?.coordinate_info?.origin_shift
|
|
253
|
+
? { x: streamMetadata.coordinate_info.origin_shift[0], y: streamMetadata.coordinate_info.origin_shift[1], z: streamMetadata.coordinate_info.origin_shift[2] }
|
|
254
|
+
: { x: 0, y: 0, z: 0 };
|
|
255
|
+
const finalCoordinateInfo = {
|
|
256
|
+
originShift,
|
|
257
|
+
// Original bounds = shifted bounds + originShift (reconstruct world coordinates)
|
|
258
|
+
originalBounds: {
|
|
259
|
+
min: {
|
|
260
|
+
x: bounds.min.x + originShift.x,
|
|
261
|
+
y: bounds.min.y + originShift.y,
|
|
262
|
+
z: bounds.min.z + originShift.z,
|
|
263
|
+
},
|
|
264
|
+
max: {
|
|
265
|
+
x: bounds.max.x + originShift.x,
|
|
266
|
+
y: bounds.max.y + originShift.y,
|
|
267
|
+
z: bounds.max.z + originShift.z,
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
// Shifted bounds = bounds as-is (server already applied shift)
|
|
271
|
+
shiftedBounds: bounds,
|
|
272
|
+
// Note: server returns is_geo_referenced but it really means "had large coordinates"
|
|
273
|
+
hasLargeCoordinates: streamMetadata?.coordinate_info?.is_geo_referenced ?? false,
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
setGeometryResult({
|
|
277
|
+
meshes: allMeshes,
|
|
278
|
+
totalVertices,
|
|
279
|
+
totalTriangles,
|
|
280
|
+
coordinateInfo: finalCoordinateInfo,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
} else if (parquetSupported) {
|
|
284
|
+
// NON-STREAMING PATH - for smaller files, use batch request (with cache check)
|
|
285
|
+
console.log(`[useIfc] Using PARQUET endpoint - 15x smaller payload, faster transfer`);
|
|
286
|
+
|
|
287
|
+
// Use Parquet endpoint - much smaller payload (~15x compression)
|
|
288
|
+
const parseStart = performance.now();
|
|
289
|
+
const parquetResult = await client.parseParquet(file);
|
|
290
|
+
result = parquetResult;
|
|
291
|
+
parseTime = performance.now() - parseStart;
|
|
292
|
+
|
|
293
|
+
console.log(`[useIfc] Server parse response received in ${parseTime.toFixed(0)}ms`);
|
|
294
|
+
console.log(` Server stats: ${parquetResult.stats.total_time_ms}ms total (parse: ${parquetResult.stats.parse_time_ms}ms, geometry: ${parquetResult.stats.geometry_time_ms}ms)`);
|
|
295
|
+
console.log(` Parquet payload: ${(parquetResult.parquet_stats.payload_size / 1024 / 1024).toFixed(2)}MB, decode: ${parquetResult.parquet_stats.decode_time_ms}ms`);
|
|
296
|
+
console.log(` Meshes: ${parquetResult.meshes.length}, Vertices: ${parquetResult.stats.total_vertices}, Triangles: ${parquetResult.stats.total_triangles}`);
|
|
297
|
+
console.log(` Cache key: ${parquetResult.cache_key}`);
|
|
298
|
+
|
|
299
|
+
setProgress({ phase: 'Converting meshes', percent: 70 });
|
|
300
|
+
|
|
301
|
+
// Convert server mesh format to viewer format (TypedArrays)
|
|
302
|
+
const convertStart = performance.now();
|
|
303
|
+
allMeshes = parquetResult.meshes.map(convertServerMesh);
|
|
304
|
+
convertTime = performance.now() - convertStart;
|
|
305
|
+
console.log(`[useIfc] Mesh conversion: ${convertTime.toFixed(0)}ms for ${allMeshes.length} meshes`);
|
|
306
|
+
} else {
|
|
307
|
+
console.log(`[useIfc] Parquet not available, using JSON endpoint (install parquet-wasm for 15x faster transfer)`);
|
|
308
|
+
console.log(`[useIfc] Using FULL PARSE (parallel) - all geometry processed at once`);
|
|
309
|
+
|
|
310
|
+
// Fallback to JSON endpoint
|
|
311
|
+
const parseStart = performance.now();
|
|
312
|
+
result = await client.parse(file);
|
|
313
|
+
parseTime = performance.now() - parseStart;
|
|
314
|
+
|
|
315
|
+
console.log(`[useIfc] Server parse response received in ${parseTime.toFixed(0)}ms`);
|
|
316
|
+
console.log(` Server stats: ${result.stats.total_time_ms}ms total (parse: ${result.stats.parse_time_ms}ms, geometry: ${result.stats.geometry_time_ms}ms)`);
|
|
317
|
+
console.log(` Meshes: ${result.meshes.length}, Vertices: ${result.stats.total_vertices}, Triangles: ${result.stats.total_triangles}`);
|
|
318
|
+
console.log(` Cache key: ${result.cache_key}`);
|
|
319
|
+
|
|
320
|
+
setProgress({ phase: 'Converting meshes', percent: 70 });
|
|
321
|
+
|
|
322
|
+
// Convert server mesh format to viewer format
|
|
323
|
+
const convertStart = performance.now();
|
|
324
|
+
const jsonResult = result as ParseResponse;
|
|
325
|
+
allMeshes = jsonResult.meshes.map(convertServerMesh);
|
|
326
|
+
convertTime = performance.now() - convertStart;
|
|
327
|
+
console.log(`[useIfc] Mesh conversion: ${convertTime.toFixed(0)}ms for ${allMeshes.length} meshes`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// For non-streaming paths, calculate bounds and set geometry
|
|
331
|
+
// (Streaming path already handled this progressively)
|
|
332
|
+
const wasStreaming = parquetSupported && fileSizeMB > USE_STREAMING_THRESHOLD_MB;
|
|
333
|
+
|
|
334
|
+
if (!wasStreaming) {
|
|
335
|
+
// Calculate bounds from mesh positions for camera fitting
|
|
336
|
+
// IMPORTANT: Server already applies RTC shift to mesh positions, so bounds calculated
|
|
337
|
+
// from mesh positions are ALREADY in shifted coordinates (small values near origin).
|
|
338
|
+
// We must NOT subtract originShift again - that would give huge negative bounds!
|
|
339
|
+
const { bounds } = calculateMeshBounds(allMeshes);
|
|
340
|
+
|
|
341
|
+
// Build CoordinateInfo correctly for server-shifted meshes:
|
|
342
|
+
// - shiftedBounds = bounds (already shifted by server)
|
|
343
|
+
// - originalBounds = bounds + originShift (reconstruct original world coordinates)
|
|
344
|
+
const serverCoordInfo = result.metadata.coordinate_info;
|
|
345
|
+
const originShift = serverCoordInfo?.origin_shift
|
|
346
|
+
? { x: serverCoordInfo.origin_shift[0], y: serverCoordInfo.origin_shift[1], z: serverCoordInfo.origin_shift[2] }
|
|
347
|
+
: { x: 0, y: 0, z: 0 };
|
|
348
|
+
|
|
349
|
+
// When server already shifted meshes, shiftedBounds IS the calculated bounds
|
|
350
|
+
// (don't use createCoordinateInfo which would subtract originShift again)
|
|
351
|
+
const coordinateInfo: CoordinateInfo = {
|
|
352
|
+
originShift,
|
|
353
|
+
// Original bounds = shifted bounds + originShift (reconstruct world coordinates)
|
|
354
|
+
originalBounds: {
|
|
355
|
+
min: {
|
|
356
|
+
x: bounds.min.x + originShift.x,
|
|
357
|
+
y: bounds.min.y + originShift.y,
|
|
358
|
+
z: bounds.min.z + originShift.z,
|
|
359
|
+
},
|
|
360
|
+
max: {
|
|
361
|
+
x: bounds.max.x + originShift.x,
|
|
362
|
+
y: bounds.max.y + originShift.y,
|
|
363
|
+
z: bounds.max.z + originShift.z,
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
// Shifted bounds = bounds as-is (server already applied shift)
|
|
367
|
+
shiftedBounds: {
|
|
368
|
+
min: { x: bounds.min.x, y: bounds.min.y, z: bounds.min.z },
|
|
369
|
+
max: { x: bounds.max.x, y: bounds.max.y, z: bounds.max.z },
|
|
370
|
+
},
|
|
371
|
+
// Note: server returns is_geo_referenced but it really means "had large coordinates"
|
|
372
|
+
hasLargeCoordinates: serverCoordInfo?.is_geo_referenced ?? false,
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
console.log(`[useIfc] Calculated bounds:`, {
|
|
376
|
+
min: `(${bounds.min.x.toFixed(1)}, ${bounds.min.y.toFixed(1)}, ${bounds.min.z.toFixed(1)})`,
|
|
377
|
+
max: `(${bounds.max.x.toFixed(1)}, ${bounds.max.y.toFixed(1)}, ${bounds.max.z.toFixed(1)})`,
|
|
378
|
+
size: `${(bounds.max.x - bounds.min.x).toFixed(1)} x ${(bounds.max.y - bounds.min.y).toFixed(1)} x ${(bounds.max.z - bounds.min.z).toFixed(1)}`,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Set all geometry at once
|
|
382
|
+
setProgress({ phase: 'Rendering geometry', percent: 80 });
|
|
383
|
+
const renderStart = performance.now();
|
|
384
|
+
setGeometryResult({
|
|
385
|
+
meshes: allMeshes,
|
|
386
|
+
totalVertices: result.stats.total_vertices,
|
|
387
|
+
totalTriangles: result.stats.total_triangles,
|
|
388
|
+
coordinateInfo,
|
|
389
|
+
});
|
|
390
|
+
const renderTime = performance.now() - renderStart;
|
|
391
|
+
console.log(`[useIfc] Geometry set: ${renderTime.toFixed(0)}ms`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Fetch and decode data model asynchronously (geometry already displayed)
|
|
395
|
+
// Data model is processed on server in background, fetch via separate endpoint
|
|
396
|
+
const cacheKey = result.cache_key;
|
|
397
|
+
|
|
398
|
+
// Start data model fetch in background - don't block rendering
|
|
399
|
+
(async () => {
|
|
400
|
+
setProgress({ phase: 'Fetching data model', percent: 85 });
|
|
401
|
+
const dataModelStart = performance.now();
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
// If data model was included in response (ParquetParseResponse), use it directly
|
|
405
|
+
// Otherwise, fetch from the data model endpoint
|
|
406
|
+
let dataModelBuffer: ArrayBuffer | null = null;
|
|
407
|
+
if ('data_model' in result && result.data_model) {
|
|
408
|
+
dataModelBuffer = result.data_model;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (!dataModelBuffer || dataModelBuffer.byteLength === 0) {
|
|
412
|
+
console.log('[useIfc] Fetching data model from server (background processing)...');
|
|
413
|
+
dataModelBuffer = await client.fetchDataModel(cacheKey);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (!dataModelBuffer) {
|
|
417
|
+
console.log('[useIfc] ⚡ Data model not available - property panel disabled');
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const dataModel: DataModel = await decodeDataModel(dataModelBuffer);
|
|
422
|
+
|
|
423
|
+
console.log(`[useIfc] Data model decoded in ${(performance.now() - dataModelStart).toFixed(0)}ms`);
|
|
424
|
+
console.log(` Entities: ${dataModel.entities.size}`);
|
|
425
|
+
console.log(` PropertySets: ${dataModel.propertySets.size}`);
|
|
426
|
+
const quantitySetsSize = (dataModel as { quantitySets?: Map<number, unknown> }).quantitySets?.size ?? 0;
|
|
427
|
+
console.log(` QuantitySets: ${quantitySetsSize}`);
|
|
428
|
+
console.log(` Relationships: ${dataModel.relationships.length}`);
|
|
429
|
+
console.log(` Spatial nodes: ${dataModel.spatialHierarchy.nodes.length}`);
|
|
430
|
+
|
|
431
|
+
// Convert server data model directly to IfcDataStore format
|
|
432
|
+
const dataStore = convertServerDataModel(
|
|
433
|
+
dataModel,
|
|
434
|
+
result as ServerParseResult,
|
|
435
|
+
file,
|
|
436
|
+
allMeshes
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
setIfcDataStore(dataStore);
|
|
440
|
+
console.log('[useIfc] ✅ Property panel ready with server data model');
|
|
441
|
+
console.log(`[useIfc] Data model loaded in ${(performance.now() - dataModelStart).toFixed(0)}ms (background)`);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
console.warn('[useIfc] Failed to decode data model:', err);
|
|
444
|
+
console.log('[useIfc] ⚡ Skipping data model (decoding failed)');
|
|
445
|
+
}
|
|
446
|
+
})(); // End of async data model fetch block - runs in background, doesn't block
|
|
447
|
+
|
|
448
|
+
// Geometry is ready - mark complete immediately (data model loads in background)
|
|
449
|
+
setProgress({ phase: 'Complete', percent: 100 });
|
|
450
|
+
const totalServerTime = performance.now() - serverStart;
|
|
451
|
+
console.log(`[useIfc] SERVER PARALLEL complete: ${file.name}`);
|
|
452
|
+
console.log(` Total time: ${totalServerTime.toFixed(0)}ms`);
|
|
453
|
+
console.log(` Breakdown: parse=${parseTime.toFixed(0)}ms, convert=${convertTime.toFixed(0)}ms`);
|
|
454
|
+
|
|
455
|
+
return true;
|
|
456
|
+
} catch (err) {
|
|
457
|
+
console.error('[useIfc] Server parse failed:', err);
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}, []);
|
|
461
|
+
|
|
462
|
+
return { loadFromServer };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export default useIfcServer;
|
|
@@ -7,12 +7,49 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { useEffect, useCallback } from 'react';
|
|
10
|
-
import { useViewerStore } from '@/store';
|
|
10
|
+
import { useViewerStore, stringToEntityRef } from '@/store';
|
|
11
|
+
import type { EntityRef } from '@/store';
|
|
11
12
|
|
|
12
13
|
interface KeyboardShortcutsOptions {
|
|
13
14
|
enabled?: boolean;
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
/** Clear multi-select state so subsequent operations use single-entity selectedEntity */
|
|
18
|
+
function clearMultiSelect(): void {
|
|
19
|
+
const state = useViewerStore.getState();
|
|
20
|
+
if (state.selectedEntitiesSet.size > 0) {
|
|
21
|
+
useViewerStore.setState({ selectedEntitiesSet: new Set(), selectedEntityIds: new Set() });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Get all selected global IDs — multi-select if available, else single selectedEntityId */
|
|
26
|
+
function getAllSelectedGlobalIds(): number[] {
|
|
27
|
+
const state = useViewerStore.getState();
|
|
28
|
+
if (state.selectedEntityIds.size > 0) {
|
|
29
|
+
return Array.from(state.selectedEntityIds);
|
|
30
|
+
}
|
|
31
|
+
if (state.selectedEntityId !== null) {
|
|
32
|
+
return [state.selectedEntityId];
|
|
33
|
+
}
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Get current selection as EntityRef[] — multi-select if available, else single */
|
|
38
|
+
function getSelectionRefsFromStore(): EntityRef[] {
|
|
39
|
+
const state = useViewerStore.getState();
|
|
40
|
+
if (state.selectedEntitiesSet.size > 0) {
|
|
41
|
+
const refs: EntityRef[] = [];
|
|
42
|
+
for (const str of state.selectedEntitiesSet) {
|
|
43
|
+
refs.push(stringToEntityRef(str));
|
|
44
|
+
}
|
|
45
|
+
return refs;
|
|
46
|
+
}
|
|
47
|
+
if (state.selectedEntity) {
|
|
48
|
+
return [state.selectedEntity];
|
|
49
|
+
}
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
|
|
16
53
|
export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
17
54
|
const { enabled = true } = options;
|
|
18
55
|
|
|
@@ -20,12 +57,18 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
|
20
57
|
const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
|
|
21
58
|
const activeTool = useViewerStore((s) => s.activeTool);
|
|
22
59
|
const setActiveTool = useViewerStore((s) => s.setActiveTool);
|
|
23
|
-
const
|
|
24
|
-
const hideEntity = useViewerStore((s) => s.hideEntity);
|
|
60
|
+
const hideEntities = useViewerStore((s) => s.hideEntities);
|
|
25
61
|
const showAll = useViewerStore((s) => s.showAll);
|
|
26
62
|
const clearStoreySelection = useViewerStore((s) => s.clearStoreySelection);
|
|
27
63
|
const toggleTheme = useViewerStore((s) => s.toggleTheme);
|
|
28
64
|
|
|
65
|
+
// Basket actions
|
|
66
|
+
const setBasket = useViewerStore((s) => s.setBasket);
|
|
67
|
+
const addToBasket = useViewerStore((s) => s.addToBasket);
|
|
68
|
+
const removeFromBasket = useViewerStore((s) => s.removeFromBasket);
|
|
69
|
+
const clearBasket = useViewerStore((s) => s.clearBasket);
|
|
70
|
+
const showPinboard = useViewerStore((s) => s.showPinboard);
|
|
71
|
+
|
|
29
72
|
// Measure tool specific actions
|
|
30
73
|
const activeMeasurement = useViewerStore((s) => s.activeMeasurement);
|
|
31
74
|
const cancelMeasurement = useViewerStore((s) => s.cancelMeasurement);
|
|
@@ -74,18 +117,67 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
|
74
117
|
setActiveTool('section');
|
|
75
118
|
}
|
|
76
119
|
|
|
77
|
-
// Visibility controls
|
|
78
|
-
|
|
120
|
+
// Basket / Visibility controls
|
|
121
|
+
// I = Set basket (isolate selection as basket), or re-apply basket if no selection
|
|
122
|
+
if (key === 'i' && !ctrl && !shift) {
|
|
123
|
+
const state = useViewerStore.getState();
|
|
124
|
+
// If basket already exists and user hasn't explicitly multi-selected,
|
|
125
|
+
// re-apply the basket instead of replacing it with a stale single selection.
|
|
126
|
+
if (state.pinboardEntities.size > 0 && state.selectedEntitiesSet.size === 0) {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
showPinboard();
|
|
129
|
+
} else {
|
|
130
|
+
const refs = getSelectionRefsFromStore();
|
|
131
|
+
if (refs.length > 0) {
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
setBasket(refs);
|
|
134
|
+
// Consume multi-select so subsequent − removes a single entity
|
|
135
|
+
clearMultiSelect();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// + or = (with shift) = Add to basket
|
|
141
|
+
if ((e.key === '+' || (e.key === '=' && shift)) && !ctrl) {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
const refs = getSelectionRefsFromStore();
|
|
144
|
+
if (refs.length > 0) {
|
|
145
|
+
addToBasket(refs);
|
|
146
|
+
// Consume multi-select so subsequent − removes a single entity
|
|
147
|
+
clearMultiSelect();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// - or _ = Remove from basket
|
|
152
|
+
if ((e.key === '-' || e.key === '_') && !ctrl) {
|
|
79
153
|
e.preventDefault();
|
|
80
|
-
|
|
154
|
+
const refs = getSelectionRefsFromStore();
|
|
155
|
+
if (refs.length > 0) {
|
|
156
|
+
removeFromBasket(refs);
|
|
157
|
+
// Consume multi-select after removal
|
|
158
|
+
clearMultiSelect();
|
|
159
|
+
}
|
|
81
160
|
}
|
|
161
|
+
|
|
82
162
|
if ((key === 'delete' || key === 'backspace') && !ctrl && !shift && selectedEntityId) {
|
|
83
163
|
e.preventDefault();
|
|
84
|
-
|
|
164
|
+
const ids = getAllSelectedGlobalIds();
|
|
165
|
+
hideEntities(ids);
|
|
166
|
+
clearMultiSelect();
|
|
167
|
+
}
|
|
168
|
+
// Space to hide — skip when focused on buttons/selects/links where Space has native behavior
|
|
169
|
+
if (key === ' ' && !ctrl && !shift && selectedEntityId) {
|
|
170
|
+
const tag = document.activeElement?.tagName;
|
|
171
|
+
if (tag !== 'BUTTON' && tag !== 'SELECT' && tag !== 'A') {
|
|
172
|
+
e.preventDefault();
|
|
173
|
+
const ids = getAllSelectedGlobalIds();
|
|
174
|
+
hideEntities(ids);
|
|
175
|
+
clearMultiSelect();
|
|
176
|
+
}
|
|
85
177
|
}
|
|
86
178
|
if (key === 'a' && !ctrl && !shift) {
|
|
87
179
|
e.preventDefault();
|
|
88
|
-
showAll();
|
|
180
|
+
showAll(); // Clear hiddenEntities + isolatedEntities (basket preserved)
|
|
89
181
|
clearStoreySelection(); // Also clear storey filtering
|
|
90
182
|
}
|
|
91
183
|
|
|
@@ -121,6 +213,7 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
|
121
213
|
if (key === 'escape') {
|
|
122
214
|
e.preventDefault();
|
|
123
215
|
setSelectedEntityId(null);
|
|
216
|
+
clearBasket();
|
|
124
217
|
showAll();
|
|
125
218
|
clearStoreySelection(); // Also clear storey filtering
|
|
126
219
|
setActiveTool('select');
|
|
@@ -139,8 +232,12 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
|
139
232
|
setSelectedEntityId,
|
|
140
233
|
activeTool,
|
|
141
234
|
setActiveTool,
|
|
142
|
-
|
|
143
|
-
|
|
235
|
+
setBasket,
|
|
236
|
+
addToBasket,
|
|
237
|
+
removeFromBasket,
|
|
238
|
+
clearBasket,
|
|
239
|
+
showPinboard,
|
|
240
|
+
hideEntities,
|
|
144
241
|
showAll,
|
|
145
242
|
clearStoreySelection,
|
|
146
243
|
toggleTheme,
|
|
@@ -171,14 +268,16 @@ export const KEYBOARD_SHORTCUTS = [
|
|
|
171
268
|
{ key: 'S', description: 'Toggle snapping (Measure tool)', category: 'Tools' },
|
|
172
269
|
{ key: 'Esc', description: 'Cancel measurement (Measure tool)', category: 'Tools' },
|
|
173
270
|
{ key: 'Ctrl+C', description: 'Clear measurements (Measure tool)', category: 'Tools' },
|
|
174
|
-
{ key: 'I', description: '
|
|
175
|
-
{ key: '
|
|
176
|
-
{ key: '
|
|
271
|
+
{ key: 'I', description: 'Set basket (isolate selection)', category: 'Visibility' },
|
|
272
|
+
{ key: '+', description: 'Add selection to basket', category: 'Visibility' },
|
|
273
|
+
{ key: '−', description: 'Remove selection from basket', category: 'Visibility' },
|
|
274
|
+
{ key: 'Del / Space', description: 'Hide selection', category: 'Visibility' },
|
|
275
|
+
{ key: 'A', description: 'Show all (clear filters, keep basket)', category: 'Visibility' },
|
|
177
276
|
{ key: 'H', description: 'Home (Isometric view)', category: 'Camera' },
|
|
178
277
|
{ key: 'Z', description: 'Fit all (zoom extents)', category: 'Camera' },
|
|
179
278
|
{ key: 'F', description: 'Frame selection', category: 'Camera' },
|
|
180
|
-
{ key: '
|
|
279
|
+
{ key: '1-6', description: 'Preset views', category: 'Camera' },
|
|
181
280
|
{ key: 'T', description: 'Toggle theme', category: 'UI' },
|
|
182
|
-
{ key: 'Esc', description: 'Reset all (clear selection,
|
|
281
|
+
{ key: 'Esc', description: 'Reset all (clear selection, basket, isolation)', category: 'Selection' },
|
|
183
282
|
{ key: '?', description: 'Show info panel', category: 'Help' },
|
|
184
283
|
] as const;
|