@ifc-lite/viewer 1.6.0 → 1.7.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 +78 -0
- package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
- package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
- package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
- package/dist/assets/index-yTqs8kgX.css +1 -0
- package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
- package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.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 +1048 -0
- package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
- package/src/components/viewer/HierarchyPanel.tsx +110 -842
- 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 +603 -0
- package/src/components/viewer/MainToolbar.tsx +188 -21
- package/src/components/viewer/PropertiesPanel.tsx +171 -663
- package/src/components/viewer/PropertyEditor.tsx +866 -77
- package/src/components/viewer/Section2DPanel.tsx +76 -2648
- package/src/components/viewer/ToolOverlays.tsx +3 -1097
- package/src/components/viewer/ViewerLayout.tsx +132 -45
- package/src/components/viewer/Viewport.tsx +237 -1659
- package/src/components/viewer/ViewportContainer.tsx +11 -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 +193 -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/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 +398 -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/useBCF.ts +7 -0
- package/src/hooks/useDrawingExport.ts +627 -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 +1 -1
- package/src/hooks/useLens.ts +129 -0
- package/src/hooks/useMeasure2D.ts +365 -0
- package/src/hooks/useViewControls.ts +218 -0
- 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 +138 -0
- package/src/lib/lens/index.ts +5 -0
- package/src/lib/lists/adapter.ts +69 -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 +38 -2
- package/src/store/slices/cameraSlice.ts +14 -1
- package/src/store/slices/dataSlice.ts +14 -1
- package/src/store/slices/lensSlice.ts +184 -0
- package/src/store/slices/listSlice.ts +74 -0
- package/src/store/slices/pinboardSlice.ts +114 -0
- package/src/store/types.ts +5 -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;
|
|
@@ -177,7 +177,7 @@ export const KEYBOARD_SHORTCUTS = [
|
|
|
177
177
|
{ key: 'H', description: 'Home (Isometric view)', category: 'Camera' },
|
|
178
178
|
{ key: 'Z', description: 'Fit all (zoom extents)', category: 'Camera' },
|
|
179
179
|
{ key: 'F', description: 'Frame selection', category: 'Camera' },
|
|
180
|
-
{ key: '
|
|
180
|
+
{ key: '1-6', description: 'Preset views', category: 'Camera' },
|
|
181
181
|
{ key: 'T', description: 'Toggle theme', category: 'UI' },
|
|
182
182
|
{ key: 'Esc', description: 'Reset all (clear selection, filters, isolation)', category: 'Selection' },
|
|
183
183
|
{ key: '?', description: 'Show info panel', category: 'Help' },
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
* Lens evaluation hook
|
|
7
|
+
*
|
|
8
|
+
* Evaluates active lens rules against all entities across all models,
|
|
9
|
+
* producing a color map and hidden IDs set that are applied to the renderer.
|
|
10
|
+
* Unmatched entities with geometry are ghosted (semi-transparent).
|
|
11
|
+
*
|
|
12
|
+
* The pure evaluation logic lives in @ifc-lite/lens — this hook handles
|
|
13
|
+
* React lifecycle, original-color capture/restore, and Zustand integration.
|
|
14
|
+
*
|
|
15
|
+
* Performance notes:
|
|
16
|
+
* - Does NOT subscribe to `models` or `ifcDataStore` — reads them from
|
|
17
|
+
* getState() only when the active lens changes. This prevents re-evaluation
|
|
18
|
+
* during model loading.
|
|
19
|
+
* - Uses `setPendingColorUpdates` instead of `updateMeshColors` to avoid
|
|
20
|
+
* cloning the entire mesh array (O(n) mesh copies) on every lens switch.
|
|
21
|
+
* - Original mesh colors are captured once and restored on deactivation.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { useEffect, useRef, useCallback } from 'react';
|
|
25
|
+
import { evaluateLens, rgbaToHex, isGhostColor } from '@ifc-lite/lens';
|
|
26
|
+
import type { RGBAColor } from '@ifc-lite/lens';
|
|
27
|
+
import { useViewerStore } from '@/store';
|
|
28
|
+
import { createLensDataProvider } from '@/lib/lens';
|
|
29
|
+
|
|
30
|
+
export function useLens() {
|
|
31
|
+
const activeLensId = useViewerStore((s) => s.activeLensId);
|
|
32
|
+
const savedLenses = useViewerStore((s) => s.savedLenses);
|
|
33
|
+
|
|
34
|
+
// Track the previously active lens to detect deactivation
|
|
35
|
+
const prevLensIdRef = useRef<string | null>(null);
|
|
36
|
+
// Track original colors to restore when lens is deactivated
|
|
37
|
+
const originalColorsRef = useRef<Map<number, RGBAColor> | null>(null);
|
|
38
|
+
|
|
39
|
+
/** Collect original mesh colors from all geometry sources (federation + legacy) */
|
|
40
|
+
const captureOriginalColors = useCallback(() => {
|
|
41
|
+
const state = useViewerStore.getState();
|
|
42
|
+
const originals = new Map<number, RGBAColor>();
|
|
43
|
+
|
|
44
|
+
// Federation mode: collect from all model geometries
|
|
45
|
+
if (state.models.size > 0) {
|
|
46
|
+
for (const [, model] of state.models) {
|
|
47
|
+
if (model.geometryResult?.meshes) {
|
|
48
|
+
for (const mesh of model.geometryResult.meshes) {
|
|
49
|
+
if (mesh.color) {
|
|
50
|
+
originals.set(mesh.expressId, mesh.color as RGBAColor);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Legacy mode: collect from store geometryResult
|
|
58
|
+
if (state.geometryResult?.meshes) {
|
|
59
|
+
for (const mesh of state.geometryResult.meshes) {
|
|
60
|
+
if (mesh.color) {
|
|
61
|
+
originals.set(mesh.expressId, mesh.color as RGBAColor);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return originals;
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const activeLens = savedLenses.find(l => l.id === activeLensId) ?? null;
|
|
71
|
+
|
|
72
|
+
// Lens deactivated — restore original colors
|
|
73
|
+
if (!activeLens && prevLensIdRef.current !== null) {
|
|
74
|
+
prevLensIdRef.current = null;
|
|
75
|
+
useViewerStore.getState().setLensColorMap(new Map());
|
|
76
|
+
useViewerStore.getState().setLensHiddenIds(new Set());
|
|
77
|
+
useViewerStore.getState().setLensRuleCounts(new Map());
|
|
78
|
+
useViewerStore.getState().setLensRuleEntityIds(new Map());
|
|
79
|
+
|
|
80
|
+
// Restore original mesh colors via lightweight pending path
|
|
81
|
+
if (originalColorsRef.current && originalColorsRef.current.size > 0) {
|
|
82
|
+
useViewerStore.getState().setPendingColorUpdates(originalColorsRef.current);
|
|
83
|
+
}
|
|
84
|
+
originalColorsRef.current = null;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!activeLens) return;
|
|
89
|
+
|
|
90
|
+
// Read data sources from getState() — NOT subscribed, so model loading
|
|
91
|
+
// doesn't trigger re-evaluation
|
|
92
|
+
const { models, ifcDataStore } = useViewerStore.getState();
|
|
93
|
+
if (models.size === 0 && !ifcDataStore) return;
|
|
94
|
+
|
|
95
|
+
// Save original colors before first lens application
|
|
96
|
+
if (prevLensIdRef.current === null) {
|
|
97
|
+
originalColorsRef.current = captureOriginalColors();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
prevLensIdRef.current = activeLensId;
|
|
101
|
+
|
|
102
|
+
// Create data provider and evaluate lens using @ifc-lite/lens package
|
|
103
|
+
const provider = createLensDataProvider(models, ifcDataStore);
|
|
104
|
+
const { colorMap, hiddenIds, ruleCounts, ruleEntityIds } = evaluateLens(activeLens, provider);
|
|
105
|
+
|
|
106
|
+
// Build hex color map for UI legend (exclude ghost entries)
|
|
107
|
+
const hexColorMap = new Map<number, string>();
|
|
108
|
+
for (const [id, rgba] of colorMap) {
|
|
109
|
+
if (!isGhostColor(rgba)) {
|
|
110
|
+
hexColorMap.set(id, rgbaToHex(rgba));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
useViewerStore.getState().setLensColorMap(hexColorMap);
|
|
114
|
+
useViewerStore.getState().setLensHiddenIds(hiddenIds);
|
|
115
|
+
useViewerStore.getState().setLensRuleCounts(ruleCounts);
|
|
116
|
+
useViewerStore.getState().setLensRuleEntityIds(ruleEntityIds);
|
|
117
|
+
|
|
118
|
+
// Apply ALL colors to renderer via pendingColorUpdates only —
|
|
119
|
+
// no mesh cloning needed, the renderer picks these up directly
|
|
120
|
+
if (colorMap.size > 0) {
|
|
121
|
+
useViewerStore.getState().setPendingColorUpdates(colorMap);
|
|
122
|
+
}
|
|
123
|
+
}, [activeLensId, savedLenses, captureOriginalColors]);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
activeLensId,
|
|
127
|
+
savedLenses,
|
|
128
|
+
};
|
|
129
|
+
}
|