@ifc-lite/viewer 1.1.7 → 1.5.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.
Files changed (164) hide show
  1. package/LICENSE +373 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/Arrow.dom-B0e15b_b.js +20 -0
  4. package/dist/assets/arrow2-bb-jcVEo.js +2 -0
  5. package/dist/assets/arrow2_bg-4Y7xYo54.wasm +0 -0
  6. package/dist/assets/arrow2_bg-BlXl-cSQ.js +1 -0
  7. package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
  8. package/dist/assets/desktop-cache-oPzaWXYE.js +1 -0
  9. package/dist/assets/event-DIOks52T.js +1 -0
  10. package/dist/assets/ifc-cache-BAN4vcd4.js +1 -0
  11. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  12. package/dist/assets/index-Dgd6vzw_.js +65252 -0
  13. package/dist/assets/index-v3mcCUPN.css +1 -0
  14. package/dist/assets/native-bridge-Ci7NLjlZ.js +111 -0
  15. package/dist/assets/wasm-bridge-Dc82YpdZ.js +1 -0
  16. package/dist/favicon-16x16-cropped.png +0 -0
  17. package/dist/favicon-16x16.png +0 -0
  18. package/dist/favicon-192x192-cropped.png +0 -0
  19. package/dist/favicon-192x192.png +0 -0
  20. package/dist/favicon-32x32-cropped.png +0 -0
  21. package/dist/favicon-32x32.png +0 -0
  22. package/dist/favicon-48x48-cropped.png +0 -0
  23. package/dist/favicon-48x48.png +0 -0
  24. package/dist/favicon-512x512-cropped.png +0 -0
  25. package/dist/favicon-512x512.png +0 -0
  26. package/dist/favicon-64x64-cropped.png +0 -0
  27. package/dist/favicon-64x64.png +0 -0
  28. package/dist/favicon-96x96-cropped.png +0 -0
  29. package/dist/favicon-96x96.png +0 -0
  30. package/dist/favicon-square-512.png +0 -0
  31. package/dist/favicon.ico +0 -0
  32. package/dist/favicon.png +0 -0
  33. package/dist/favicon.svg +3 -0
  34. package/dist/index.html +44 -0
  35. package/dist/logo.png +0 -0
  36. package/dist/manifest.json +48 -0
  37. package/index.html +33 -2
  38. package/package.json +34 -17
  39. package/public/apple-touch-icon.png +0 -0
  40. package/public/favicon-16x16-cropped.png +0 -0
  41. package/public/favicon-16x16.png +0 -0
  42. package/public/favicon-192x192-cropped.png +0 -0
  43. package/public/favicon-192x192.png +0 -0
  44. package/public/favicon-32x32-cropped.png +0 -0
  45. package/public/favicon-32x32.png +0 -0
  46. package/public/favicon-48x48-cropped.png +0 -0
  47. package/public/favicon-48x48.png +0 -0
  48. package/public/favicon-512x512-cropped.png +0 -0
  49. package/public/favicon-512x512.png +0 -0
  50. package/public/favicon-64x64-cropped.png +0 -0
  51. package/public/favicon-64x64.png +0 -0
  52. package/public/favicon-96x96-cropped.png +0 -0
  53. package/public/favicon-96x96.png +0 -0
  54. package/public/favicon-square-512.png +0 -0
  55. package/public/favicon.ico +0 -0
  56. package/public/favicon.png +0 -0
  57. package/public/favicon.svg +3 -0
  58. package/public/logo.png +0 -0
  59. package/public/manifest.json +48 -0
  60. package/src/App.tsx +2 -0
  61. package/src/components/ui/alert.tsx +62 -0
  62. package/src/components/ui/badge.tsx +39 -0
  63. package/src/components/ui/dialog.tsx +120 -0
  64. package/src/components/ui/label.tsx +27 -0
  65. package/src/components/ui/select.tsx +151 -0
  66. package/src/components/ui/switch.tsx +30 -0
  67. package/src/components/ui/table.tsx +120 -0
  68. package/src/components/ui/tabs.tsx +1 -1
  69. package/src/components/viewer/BCFPanel.tsx +1164 -0
  70. package/src/components/viewer/BulkPropertyEditor.tsx +875 -0
  71. package/src/components/viewer/DataConnector.tsx +840 -0
  72. package/src/components/viewer/DrawingSettingsPanel.tsx +536 -0
  73. package/src/components/viewer/EntityContextMenu.tsx +45 -17
  74. package/src/components/viewer/ExportChangesButton.tsx +195 -0
  75. package/src/components/viewer/ExportDialog.tsx +402 -0
  76. package/src/components/viewer/HierarchyPanel.tsx +1132 -218
  77. package/src/components/viewer/IDSPanel.tsx +661 -0
  78. package/src/components/viewer/KeyboardShortcutsDialog.tsx +245 -39
  79. package/src/components/viewer/MainToolbar.tsx +418 -94
  80. package/src/components/viewer/PropertiesPanel.tsx +1355 -91
  81. package/src/components/viewer/PropertyEditor.tsx +611 -0
  82. package/src/components/viewer/Section2DPanel.tsx +3313 -0
  83. package/src/components/viewer/SheetSetupPanel.tsx +502 -0
  84. package/src/components/viewer/StatusBar.tsx +27 -16
  85. package/src/components/viewer/TitleBlockEditor.tsx +437 -0
  86. package/src/components/viewer/ToolOverlays.tsx +935 -127
  87. package/src/components/viewer/ViewerLayout.tsx +40 -11
  88. package/src/components/viewer/Viewport.tsx +1276 -336
  89. package/src/components/viewer/ViewportContainer.tsx +554 -18
  90. package/src/components/viewer/ViewportOverlays.tsx +24 -7
  91. package/src/hooks/useBCF.ts +504 -0
  92. package/src/hooks/useIDS.ts +1065 -0
  93. package/src/hooks/useIfc.ts +1534 -205
  94. package/src/hooks/useIfcCache.ts +279 -0
  95. package/src/hooks/useKeyboardShortcuts.ts +50 -8
  96. package/src/hooks/useModelSelection.ts +61 -0
  97. package/src/hooks/useViewerSelectors.ts +218 -0
  98. package/src/hooks/useWebGPU.ts +80 -0
  99. package/src/index.css +265 -27
  100. package/src/lib/platform.ts +23 -0
  101. package/src/services/cacheService.ts +142 -0
  102. package/src/services/desktop-cache.ts +143 -0
  103. package/src/services/fs-cache.ts +212 -0
  104. package/src/services/ifc-cache.ts +14 -6
  105. package/src/store/constants.ts +85 -0
  106. package/src/store/index.ts +214 -0
  107. package/src/store/slices/bcfSlice.ts +372 -0
  108. package/src/store/slices/cameraSlice.ts +63 -0
  109. package/src/store/slices/dataSlice.test.ts +226 -0
  110. package/src/store/slices/dataSlice.ts +112 -0
  111. package/src/store/slices/drawing2DSlice.ts +340 -0
  112. package/src/store/slices/hoverSlice.ts +40 -0
  113. package/src/store/slices/idsSlice.ts +310 -0
  114. package/src/store/slices/loadingSlice.ts +33 -0
  115. package/src/store/slices/measurementSlice.test.ts +217 -0
  116. package/src/store/slices/measurementSlice.ts +293 -0
  117. package/src/store/slices/modelSlice.test.ts +271 -0
  118. package/src/store/slices/modelSlice.ts +211 -0
  119. package/src/store/slices/mutationSlice.ts +502 -0
  120. package/src/store/slices/sectionSlice.test.ts +125 -0
  121. package/src/store/slices/sectionSlice.ts +58 -0
  122. package/src/store/slices/selectionSlice.test.ts +286 -0
  123. package/src/store/slices/selectionSlice.ts +263 -0
  124. package/src/store/slices/sheetSlice.ts +565 -0
  125. package/src/store/slices/uiSlice.ts +58 -0
  126. package/src/store/slices/visibilitySlice.test.ts +304 -0
  127. package/src/store/slices/visibilitySlice.ts +277 -0
  128. package/src/store/types.test.ts +135 -0
  129. package/src/store/types.ts +248 -0
  130. package/src/store.ts +40 -515
  131. package/src/utils/ifcConfig.ts +82 -0
  132. package/src/utils/localParsingUtils.ts +287 -0
  133. package/src/utils/serverDataModel.ts +783 -0
  134. package/src/utils/spatialHierarchy.ts +283 -0
  135. package/src/utils/viewportUtils.ts +334 -0
  136. package/src/vite-env.d.ts +23 -0
  137. package/src/webgpu-types.d.ts +128 -0
  138. package/src-tauri/Cargo.toml +29 -0
  139. package/src-tauri/build.rs +7 -0
  140. package/src-tauri/capabilities/default.json +18 -0
  141. package/src-tauri/icons/128x128.png +0 -0
  142. package/src-tauri/icons/128x128@2x.png +0 -0
  143. package/src-tauri/icons/32x32.png +0 -0
  144. package/src-tauri/icons/Square107x107Logo.png +0 -0
  145. package/src-tauri/icons/Square142x142Logo.png +0 -0
  146. package/src-tauri/icons/Square150x150Logo.png +0 -0
  147. package/src-tauri/icons/Square284x284Logo.png +0 -0
  148. package/src-tauri/icons/Square30x30Logo.png +0 -0
  149. package/src-tauri/icons/Square310x310Logo.png +0 -0
  150. package/src-tauri/icons/Square44x44Logo.png +0 -0
  151. package/src-tauri/icons/Square71x71Logo.png +0 -0
  152. package/src-tauri/icons/Square89x89Logo.png +0 -0
  153. package/src-tauri/icons/StoreLogo.png +0 -0
  154. package/src-tauri/icons/icon.icns +0 -0
  155. package/src-tauri/icons/icon.ico +0 -0
  156. package/src-tauri/icons/icon.png +0 -0
  157. package/src-tauri/src/lib.rs +21 -0
  158. package/src-tauri/src/main.rs +10 -0
  159. package/src-tauri/tauri.conf.json +39 -0
  160. package/vite.config.ts +174 -26
  161. package/public/ifc-lite_bg.wasm +0 -0
  162. package/public/web-ifc.wasm +0 -0
  163. package/src/components/Viewport.tsx +0 -723
  164. package/src/components/viewer/BoxSelectionOverlay.tsx +0 -53
@@ -0,0 +1,840 @@
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
+ * Data Connector UI - Import data from CSV files and map to IFC properties
7
+ *
8
+ * Full integration with CsvConnector from @ifc-lite/mutations
9
+ */
10
+
11
+ import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
12
+ import {
13
+ Upload,
14
+ FileSpreadsheet,
15
+ Link2,
16
+ ArrowRight,
17
+ Check,
18
+ AlertCircle,
19
+ Loader2,
20
+ Trash2,
21
+ Plus,
22
+ Eye,
23
+ Play,
24
+ Wand2,
25
+ } from 'lucide-react';
26
+ import { Button } from '@/components/ui/button';
27
+ import { Input } from '@/components/ui/input';
28
+ import { Label } from '@/components/ui/label';
29
+ import { Badge } from '@/components/ui/badge';
30
+ import {
31
+ Select,
32
+ SelectContent,
33
+ SelectItem,
34
+ SelectTrigger,
35
+ SelectValue,
36
+ } from '@/components/ui/select';
37
+ import {
38
+ Dialog,
39
+ DialogContent,
40
+ DialogDescription,
41
+ DialogFooter,
42
+ DialogHeader,
43
+ DialogTitle,
44
+ DialogTrigger,
45
+ } from '@/components/ui/dialog';
46
+ import {
47
+ Alert,
48
+ AlertDescription,
49
+ AlertTitle,
50
+ } from '@/components/ui/alert';
51
+ import {
52
+ Table,
53
+ TableBody,
54
+ TableCell,
55
+ TableHead,
56
+ TableHeader,
57
+ TableRow,
58
+ } from '@/components/ui/table';
59
+ import { ScrollArea } from '@/components/ui/scroll-area';
60
+ import { Separator } from '@/components/ui/separator';
61
+ import { useViewerStore } from '@/store';
62
+ import { useIfc } from '@/hooks/useIfc';
63
+ import { PropertyValueType } from '@ifc-lite/data';
64
+ import {
65
+ CsvConnector,
66
+ MutablePropertyView,
67
+ type CsvRow,
68
+ type MatchStrategy,
69
+ type PropertyMapping,
70
+ type DataMapping,
71
+ type MatchResult,
72
+ type ImportStats,
73
+ } from '@ifc-lite/mutations';
74
+ import { extractPropertiesOnDemand, type IfcDataStore } from '@ifc-lite/parser';
75
+
76
+ type MatchType = 'globalId' | 'expressId' | 'name' | 'property';
77
+
78
+ interface DataConnectorProps {
79
+ trigger?: React.ReactNode;
80
+ }
81
+
82
+ interface CsvColumn {
83
+ name: string;
84
+ sampleValues: string[];
85
+ }
86
+
87
+ interface MappingRow {
88
+ id: string;
89
+ sourceColumn: string;
90
+ targetPset: string;
91
+ targetProperty: string;
92
+ valueType: PropertyValueType;
93
+ }
94
+
95
+ export function DataConnector({ trigger }: DataConnectorProps) {
96
+ const { models } = useIfc();
97
+ const getMutationView = useViewerStore((s) => s.getMutationView);
98
+ const registerMutationView = useViewerStore((s) => s.registerMutationView);
99
+ // Also get legacy single-model state for backward compatibility
100
+ const legacyIfcDataStore = useViewerStore((s) => s.ifcDataStore);
101
+ const legacyGeometryResult = useViewerStore((s) => s.geometryResult);
102
+
103
+ const fileInputRef = useRef<HTMLInputElement>(null);
104
+ const [open, setOpen] = useState(false);
105
+ const [selectedModelId, setSelectedModelId] = useState<string>('');
106
+
107
+ // Raw CSV content
108
+ const [csvContent, setCsvContent] = useState<string>('');
109
+ const [fileName, setFileName] = useState<string>('');
110
+
111
+ // Parsed CSV data for preview
112
+ const [parsedRows, setParsedRows] = useState<CsvRow[]>([]);
113
+ const [csvColumns, setCsvColumns] = useState<CsvColumn[]>([]);
114
+
115
+ // Matching configuration
116
+ const [matchType, setMatchType] = useState<MatchType>('globalId');
117
+ const [matchColumn, setMatchColumn] = useState<string>('');
118
+ const [matchPset, setMatchPset] = useState<string>('');
119
+ const [matchProp, setMatchProp] = useState<string>('');
120
+
121
+ // Property mappings
122
+ const [mappings, setMappings] = useState<MappingRow[]>([]);
123
+
124
+ // Results
125
+ const [matchResults, setMatchResults] = useState<MatchResult[] | null>(null);
126
+ const [importStats, setImportStats] = useState<ImportStats | null>(null);
127
+ const [isProcessing, setIsProcessing] = useState(false);
128
+ const [error, setError] = useState<string | null>(null);
129
+
130
+ // Get list of models - includes both federated models and legacy single-model
131
+ const modelList = useMemo(() => {
132
+ const list = Array.from(models.values()).map((m) => ({
133
+ id: m.id,
134
+ name: m.name,
135
+ }));
136
+
137
+ // If no models in Map but legacy data exists, add a synthetic entry
138
+ if (list.length === 0 && legacyIfcDataStore) {
139
+ list.push({
140
+ id: '__legacy__',
141
+ name: 'Current Model',
142
+ });
143
+ }
144
+
145
+ return list;
146
+ }, [models, legacyIfcDataStore]);
147
+
148
+ // Get selected model's data - supports both federated and legacy mode
149
+ const selectedModel = useMemo(() => {
150
+ if (selectedModelId === '__legacy__' && legacyIfcDataStore && legacyGeometryResult) {
151
+ // Return a synthetic FederatedModel-like object for legacy mode
152
+ return {
153
+ id: '__legacy__',
154
+ name: 'Current Model',
155
+ ifcDataStore: legacyIfcDataStore,
156
+ geometryResult: legacyGeometryResult,
157
+ visible: true,
158
+ collapsed: false,
159
+ };
160
+ }
161
+ return models.get(selectedModelId);
162
+ }, [models, selectedModelId, legacyIfcDataStore, legacyGeometryResult]);
163
+
164
+ // Auto-select first model
165
+ useMemo(() => {
166
+ if (modelList.length > 0 && !selectedModelId) {
167
+ setSelectedModelId(modelList[0].id);
168
+ }
169
+ }, [modelList, selectedModelId]);
170
+
171
+ // Ensure mutation view exists for selected model
172
+ useEffect(() => {
173
+ if (!selectedModel?.ifcDataStore || !selectedModelId) return;
174
+
175
+ // Check if mutation view already exists
176
+ let mutationView = getMutationView(selectedModelId);
177
+ if (mutationView) return;
178
+
179
+ // Create new mutation view with on-demand property extractor
180
+ const dataStore = selectedModel.ifcDataStore;
181
+ mutationView = new MutablePropertyView(dataStore.properties || null, selectedModelId);
182
+
183
+ // Set up on-demand property extraction if the data store supports it
184
+ if (dataStore.onDemandPropertyMap && dataStore.source?.length > 0) {
185
+ mutationView.setOnDemandExtractor((entityId: number) => {
186
+ return extractPropertiesOnDemand(dataStore as IfcDataStore, entityId);
187
+ });
188
+ }
189
+
190
+ // Register the mutation view
191
+ registerMutationView(selectedModelId, mutationView);
192
+ }, [selectedModel, selectedModelId, getMutationView, registerMutationView]);
193
+
194
+ // Create CsvConnector instance
195
+ const csvConnector = useMemo(() => {
196
+ if (!selectedModel?.ifcDataStore) return null;
197
+
198
+ const mutationView = getMutationView(selectedModelId);
199
+ if (!mutationView) return null;
200
+
201
+ const dataStore = selectedModel.ifcDataStore;
202
+
203
+ return new CsvConnector(
204
+ dataStore.entities,
205
+ mutationView,
206
+ dataStore.strings || null
207
+ );
208
+ }, [selectedModel, selectedModelId, getMutationView]);
209
+
210
+ // Parse CSV file
211
+ const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
212
+ const file = e.target.files?.[0];
213
+ if (!file) return;
214
+
215
+ setFileName(file.name);
216
+ setMatchResults(null);
217
+ setImportStats(null);
218
+ setError(null);
219
+
220
+ const reader = new FileReader();
221
+ reader.onload = (event) => {
222
+ const text = event.target?.result as string;
223
+ if (!text) return;
224
+
225
+ setCsvContent(text);
226
+
227
+ // Use CsvConnector to parse if available, otherwise do basic parsing for preview
228
+ if (csvConnector) {
229
+ try {
230
+ const rows = csvConnector.parse(text);
231
+ setParsedRows(rows);
232
+
233
+ // Extract column names and sample values
234
+ if (rows.length > 0) {
235
+ const headers = Object.keys(rows[0]);
236
+ const columns: CsvColumn[] = headers.map((name) => ({
237
+ name,
238
+ sampleValues: rows.slice(0, 3).map((row) => row[name] || ''),
239
+ }));
240
+ setCsvColumns(columns);
241
+
242
+ // Auto-detect match column
243
+ const globalIdCol = columns.find(
244
+ (c) =>
245
+ c.name.toLowerCase().includes('globalid') ||
246
+ c.name.toLowerCase().includes('guid')
247
+ );
248
+ if (globalIdCol) {
249
+ setMatchColumn(globalIdCol.name);
250
+ setMatchType('globalId');
251
+ }
252
+
253
+ // Auto-detect property mappings
254
+ const autoMappings = csvConnector.autoDetectMappings(headers);
255
+ const mappingRows: MappingRow[] = autoMappings.map((m, idx) => ({
256
+ id: `auto_${idx}_${Date.now()}`,
257
+ sourceColumn: m.sourceColumn,
258
+ targetPset: m.targetPset,
259
+ targetProperty: m.targetProperty,
260
+ valueType: m.valueType,
261
+ }));
262
+
263
+ // Filter out ID columns from auto mappings
264
+ const filteredMappings = mappingRows.filter(
265
+ (m) =>
266
+ !m.sourceColumn.toLowerCase().includes('globalid') &&
267
+ !m.sourceColumn.toLowerCase().includes('expressid') &&
268
+ !m.sourceColumn.toLowerCase().includes('guid') &&
269
+ m.sourceColumn.toLowerCase() !== 'id'
270
+ );
271
+
272
+ if (filteredMappings.length > 0) {
273
+ setMappings(filteredMappings);
274
+ }
275
+ }
276
+ } catch (err) {
277
+ setError(`Failed to parse CSV: ${err instanceof Error ? err.message : 'Unknown error'}`);
278
+ }
279
+ } else {
280
+ // Basic parsing for preview before model selected
281
+ const lines = text.split('\n').filter((line) => line.trim());
282
+ if (lines.length < 2) {
283
+ setError('CSV must have at least a header row and one data row');
284
+ return;
285
+ }
286
+
287
+ const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
288
+ const columns: CsvColumn[] = headers.map((name, idx) => ({
289
+ name,
290
+ sampleValues: lines
291
+ .slice(1, 4)
292
+ .map((line) => {
293
+ const values = line.split(',');
294
+ return values[idx]?.trim().replace(/^"|"$/g, '') || '';
295
+ }),
296
+ }));
297
+ setCsvColumns(columns);
298
+
299
+ // Auto-detect match column
300
+ const globalIdCol = columns.find(
301
+ (c) =>
302
+ c.name.toLowerCase().includes('globalid') ||
303
+ c.name.toLowerCase().includes('guid')
304
+ );
305
+ if (globalIdCol) {
306
+ setMatchColumn(globalIdCol.name);
307
+ setMatchType('globalId');
308
+ }
309
+ }
310
+ };
311
+
312
+ reader.readAsText(file);
313
+ e.target.value = ''; // Reset input
314
+ }, [csvConnector]);
315
+
316
+ // Re-parse when model changes and we have content
317
+ const handleModelChange = useCallback((modelId: string) => {
318
+ setSelectedModelId(modelId);
319
+ setMatchResults(null);
320
+ setImportStats(null);
321
+ setError(null);
322
+ }, []);
323
+
324
+ // Add a mapping row
325
+ const addMapping = useCallback(() => {
326
+ setMappings((prev) => [
327
+ ...prev,
328
+ {
329
+ id: `mapping_${Date.now()}`,
330
+ sourceColumn: '',
331
+ targetPset: 'Pset_Custom',
332
+ targetProperty: '',
333
+ valueType: PropertyValueType.String,
334
+ },
335
+ ]);
336
+ }, []);
337
+
338
+ // Remove a mapping
339
+ const removeMapping = useCallback((id: string) => {
340
+ setMappings((prev) => prev.filter((m) => m.id !== id));
341
+ }, []);
342
+
343
+ // Update a mapping
344
+ const updateMapping = useCallback(
345
+ (id: string, field: keyof MappingRow, value: string | number) => {
346
+ setMappings((prev) =>
347
+ prev.map((m) => (m.id === id ? { ...m, [field]: value } : m))
348
+ );
349
+ },
350
+ []
351
+ );
352
+
353
+ // Auto-detect mappings
354
+ const handleAutoDetect = useCallback(() => {
355
+ if (!csvConnector || csvColumns.length === 0) return;
356
+
357
+ const headers = csvColumns.map((c) => c.name);
358
+ const autoMappings = csvConnector.autoDetectMappings(headers);
359
+
360
+ const mappingRows: MappingRow[] = autoMappings
361
+ .filter(
362
+ (m) =>
363
+ !m.sourceColumn.toLowerCase().includes('globalid') &&
364
+ !m.sourceColumn.toLowerCase().includes('expressid') &&
365
+ !m.sourceColumn.toLowerCase().includes('guid') &&
366
+ m.sourceColumn.toLowerCase() !== 'id'
367
+ )
368
+ .map((m, idx) => ({
369
+ id: `auto_${idx}_${Date.now()}`,
370
+ sourceColumn: m.sourceColumn,
371
+ targetPset: m.targetPset,
372
+ targetProperty: m.targetProperty,
373
+ valueType: m.valueType,
374
+ }));
375
+
376
+ setMappings(mappingRows);
377
+ }, [csvConnector, csvColumns]);
378
+
379
+ // Build DataMapping from UI state
380
+ const buildDataMapping = useCallback((): DataMapping | null => {
381
+ if (!matchColumn) return null;
382
+
383
+ const matchStrategy: MatchStrategy =
384
+ matchType === 'property'
385
+ ? { type: 'property', psetName: matchPset, propName: matchProp, column: matchColumn }
386
+ : { type: matchType, column: matchColumn };
387
+
388
+ const propertyMappings: PropertyMapping[] = mappings
389
+ .filter((m) => m.sourceColumn && m.targetProperty)
390
+ .map((m) => ({
391
+ sourceColumn: m.sourceColumn,
392
+ targetPset: m.targetPset,
393
+ targetProperty: m.targetProperty,
394
+ valueType: m.valueType,
395
+ }));
396
+
397
+ return { matchStrategy, propertyMappings };
398
+ }, [matchColumn, matchType, matchPset, matchProp, mappings]);
399
+
400
+ // Preview matches using CsvConnector.preview
401
+ const handlePreview = useCallback(() => {
402
+ if (!csvConnector || !csvContent || !matchColumn) return;
403
+
404
+ setIsProcessing(true);
405
+ setMatchResults(null);
406
+ setImportStats(null);
407
+ setError(null);
408
+
409
+ try {
410
+ const dataMapping = buildDataMapping();
411
+ if (!dataMapping) {
412
+ setError('Invalid mapping configuration');
413
+ setIsProcessing(false);
414
+ return;
415
+ }
416
+
417
+ // Use CsvConnector preview method
418
+ const preview = csvConnector.preview(csvContent, dataMapping);
419
+
420
+ setParsedRows(preview.rows);
421
+ setMatchResults(preview.matches);
422
+ } catch (err) {
423
+ console.error('Preview failed:', err);
424
+ setError(`Preview failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
425
+ } finally {
426
+ setIsProcessing(false);
427
+ }
428
+ }, [csvConnector, csvContent, matchColumn, buildDataMapping]);
429
+
430
+ // Import using CsvConnector.import
431
+ const handleImport = useCallback(() => {
432
+ if (!csvConnector || !csvContent) return;
433
+
434
+ setIsProcessing(true);
435
+ setImportStats(null);
436
+ setError(null);
437
+
438
+ try {
439
+ const dataMapping = buildDataMapping();
440
+ if (!dataMapping) {
441
+ setError('Invalid mapping configuration');
442
+ setIsProcessing(false);
443
+ return;
444
+ }
445
+
446
+ // Use CsvConnector import method - this creates mutations via the MutablePropertyView
447
+ const stats = csvConnector.import(csvContent, dataMapping);
448
+
449
+ setImportStats(stats);
450
+
451
+ if (stats.errors.length > 0) {
452
+ setError(stats.errors.join('\n'));
453
+ }
454
+ } catch (err) {
455
+ console.error('Import failed:', err);
456
+ setError(`Import failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
457
+ } finally {
458
+ setIsProcessing(false);
459
+ }
460
+ }, [csvConnector, csvContent, buildDataMapping]);
461
+
462
+ // Stats from match results
463
+ const matchStats = useMemo(() => {
464
+ if (!matchResults) return null;
465
+ const matched = matchResults.filter((r) => r.matchedEntityIds.length > 0).length;
466
+ const unmatched = matchResults.filter((r) => r.matchedEntityIds.length === 0).length;
467
+ const multiMatch = matchResults.filter((r) => r.matchedEntityIds.length > 1).length;
468
+ const highConfidence = matchResults.filter((r) => r.confidence === 1).length;
469
+ return { matched, unmatched, multiMatch, highConfidence, total: matchResults.length };
470
+ }, [matchResults]);
471
+
472
+ // Convert parsed rows to array for table display
473
+ const previewData = useMemo(() => {
474
+ if (parsedRows.length === 0) return [];
475
+ return parsedRows.slice(0, 5).map((row) => {
476
+ return csvColumns.map((col) => row[col.name] || '');
477
+ });
478
+ }, [parsedRows, csvColumns]);
479
+
480
+ return (
481
+ <Dialog open={open} onOpenChange={setOpen}>
482
+ <DialogTrigger asChild>
483
+ {trigger || (
484
+ <Button variant="outline" size="sm">
485
+ <Upload className="h-4 w-4 mr-2" />
486
+ Import Data
487
+ </Button>
488
+ )}
489
+ </DialogTrigger>
490
+ <DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
491
+ <DialogHeader>
492
+ <DialogTitle className="flex items-center gap-2">
493
+ <FileSpreadsheet className="h-5 w-5" />
494
+ Import External Data
495
+ </DialogTitle>
496
+ <DialogDescription>
497
+ Import property data from CSV files and map to IFC entities using CsvConnector
498
+ </DialogDescription>
499
+ </DialogHeader>
500
+
501
+ <div className="space-y-6 py-4">
502
+ {/* Model selector - first so CsvConnector can be created */}
503
+ <div className="space-y-2">
504
+ <Label className="text-sm font-medium">Target Model</Label>
505
+ <Select value={selectedModelId} onValueChange={handleModelChange}>
506
+ <SelectTrigger>
507
+ <SelectValue placeholder="Select a model" />
508
+ </SelectTrigger>
509
+ <SelectContent>
510
+ {modelList.map((m) => (
511
+ <SelectItem key={m.id} value={m.id}>
512
+ {m.name}
513
+ </SelectItem>
514
+ ))}
515
+ </SelectContent>
516
+ </Select>
517
+ {selectedModelId && !csvConnector && (
518
+ <p className="text-xs text-amber-600">
519
+ Note: MutationView not available for this model. Some features may be limited.
520
+ </p>
521
+ )}
522
+ </div>
523
+
524
+ {/* File Upload */}
525
+ <div className="space-y-2">
526
+ <Label className="text-sm font-medium">CSV File</Label>
527
+ <input
528
+ ref={fileInputRef}
529
+ type="file"
530
+ accept=".csv"
531
+ onChange={handleFileSelect}
532
+ className="hidden"
533
+ />
534
+ <div className="flex items-center gap-2">
535
+ <Button variant="outline" onClick={() => fileInputRef.current?.click()}>
536
+ <Upload className="h-4 w-4 mr-2" />
537
+ Choose File
538
+ </Button>
539
+ {fileName && <Badge variant="secondary">{fileName}</Badge>}
540
+ </div>
541
+ </div>
542
+
543
+ {csvColumns.length > 0 && (
544
+ <>
545
+ <Separator />
546
+
547
+ {/* CSV Preview */}
548
+ <div className="space-y-2">
549
+ <Label className="text-sm font-medium">Data Preview</Label>
550
+ <ScrollArea className="h-32 border rounded-md">
551
+ <Table>
552
+ <TableHeader>
553
+ <TableRow>
554
+ {csvColumns.map((col) => (
555
+ <TableHead key={col.name} className="text-xs whitespace-nowrap">
556
+ {col.name}
557
+ </TableHead>
558
+ ))}
559
+ </TableRow>
560
+ </TableHeader>
561
+ <TableBody>
562
+ {previewData.map((row, rowIdx) => (
563
+ <TableRow key={rowIdx}>
564
+ {row.map((cell, cellIdx) => (
565
+ <TableCell key={cellIdx} className="text-xs py-1">
566
+ {cell || '—'}
567
+ </TableCell>
568
+ ))}
569
+ </TableRow>
570
+ ))}
571
+ </TableBody>
572
+ </Table>
573
+ </ScrollArea>
574
+ <p className="text-xs text-muted-foreground">
575
+ {parsedRows.length > 0
576
+ ? `${parsedRows.length} rows parsed`
577
+ : `${csvColumns[0]?.sampleValues.length || 0} sample rows`}
578
+ </p>
579
+ </div>
580
+
581
+ <Separator />
582
+
583
+ {/* Matching Configuration */}
584
+ <div className="space-y-4">
585
+ <Label className="text-sm font-medium flex items-center gap-2">
586
+ <Link2 className="h-4 w-4" />
587
+ Match Configuration
588
+ </Label>
589
+
590
+ <div className="grid grid-cols-2 gap-4">
591
+ <div className="space-y-2">
592
+ <Label className="text-xs text-muted-foreground">Match By</Label>
593
+ <Select value={matchType} onValueChange={(v) => setMatchType(v as MatchType)}>
594
+ <SelectTrigger>
595
+ <SelectValue />
596
+ </SelectTrigger>
597
+ <SelectContent>
598
+ <SelectItem value="globalId">GlobalId</SelectItem>
599
+ <SelectItem value="expressId">EXPRESS ID</SelectItem>
600
+ <SelectItem value="name">Entity Name</SelectItem>
601
+ <SelectItem value="property">Property Value</SelectItem>
602
+ </SelectContent>
603
+ </Select>
604
+ </div>
605
+
606
+ <div className="space-y-2">
607
+ <Label className="text-xs text-muted-foreground">Match Column</Label>
608
+ <Select value={matchColumn} onValueChange={setMatchColumn}>
609
+ <SelectTrigger>
610
+ <SelectValue placeholder="Select column" />
611
+ </SelectTrigger>
612
+ <SelectContent>
613
+ {csvColumns.map((col) => (
614
+ <SelectItem key={col.name} value={col.name}>
615
+ {col.name}
616
+ {col.sampleValues[0] && (
617
+ <span className="ml-2 text-muted-foreground">
618
+ (e.g., {col.sampleValues[0].slice(0, 20)})
619
+ </span>
620
+ )}
621
+ </SelectItem>
622
+ ))}
623
+ </SelectContent>
624
+ </Select>
625
+ </div>
626
+ </div>
627
+
628
+ {matchType === 'property' && (
629
+ <div className="grid grid-cols-2 gap-4">
630
+ <div className="space-y-2">
631
+ <Label className="text-xs text-muted-foreground">Property Set</Label>
632
+ <Input
633
+ value={matchPset}
634
+ onChange={(e) => setMatchPset(e.target.value)}
635
+ placeholder="e.g., Pset_WallCommon"
636
+ />
637
+ </div>
638
+ <div className="space-y-2">
639
+ <Label className="text-xs text-muted-foreground">Property Name</Label>
640
+ <Input
641
+ value={matchProp}
642
+ onChange={(e) => setMatchProp(e.target.value)}
643
+ placeholder="e.g., Reference"
644
+ />
645
+ </div>
646
+ </div>
647
+ )}
648
+ </div>
649
+
650
+ <Separator />
651
+
652
+ {/* Property Mappings */}
653
+ <div className="space-y-4">
654
+ <div className="flex items-center justify-between">
655
+ <Label className="text-sm font-medium">Property Mappings</Label>
656
+ <div className="flex items-center gap-2">
657
+ {csvConnector && (
658
+ <Button variant="ghost" size="sm" onClick={handleAutoDetect}>
659
+ <Wand2 className="h-3 w-3 mr-1" />
660
+ Auto-detect
661
+ </Button>
662
+ )}
663
+ <Button variant="ghost" size="sm" onClick={addMapping}>
664
+ <Plus className="h-3 w-3 mr-1" />
665
+ Add Mapping
666
+ </Button>
667
+ </div>
668
+ </div>
669
+
670
+ {mappings.length === 0 ? (
671
+ <p className="text-sm text-muted-foreground text-center py-4">
672
+ Add mappings to import column values as IFC properties
673
+ </p>
674
+ ) : (
675
+ <div className="space-y-2">
676
+ {mappings.map((mapping) => (
677
+ <div
678
+ key={mapping.id}
679
+ className="flex items-center gap-2 p-2 border rounded-md bg-muted/30"
680
+ >
681
+ <Select
682
+ value={mapping.sourceColumn}
683
+ onValueChange={(v) => updateMapping(mapping.id, 'sourceColumn', v)}
684
+ >
685
+ <SelectTrigger className="h-8 w-32">
686
+ <SelectValue placeholder="Column" />
687
+ </SelectTrigger>
688
+ <SelectContent>
689
+ {csvColumns.map((col) => (
690
+ <SelectItem key={col.name} value={col.name}>
691
+ {col.name}
692
+ </SelectItem>
693
+ ))}
694
+ </SelectContent>
695
+ </Select>
696
+
697
+ <ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
698
+
699
+ <Input
700
+ placeholder="Pset name"
701
+ value={mapping.targetPset}
702
+ onChange={(e) => updateMapping(mapping.id, 'targetPset', e.target.value)}
703
+ className="h-8 text-xs w-32"
704
+ />
705
+
706
+ <Input
707
+ placeholder="Property"
708
+ value={mapping.targetProperty}
709
+ onChange={(e) =>
710
+ updateMapping(mapping.id, 'targetProperty', e.target.value)
711
+ }
712
+ className="h-8 text-xs flex-1"
713
+ />
714
+
715
+ <Select
716
+ value={mapping.valueType.toString()}
717
+ onValueChange={(v) => updateMapping(mapping.id, 'valueType', parseInt(v))}
718
+ >
719
+ <SelectTrigger className="h-8 w-24">
720
+ <SelectValue />
721
+ </SelectTrigger>
722
+ <SelectContent>
723
+ <SelectItem value={PropertyValueType.String.toString()}>
724
+ String
725
+ </SelectItem>
726
+ <SelectItem value={PropertyValueType.Real.toString()}>Real</SelectItem>
727
+ <SelectItem value={PropertyValueType.Integer.toString()}>
728
+ Integer
729
+ </SelectItem>
730
+ <SelectItem value={PropertyValueType.Boolean.toString()}>
731
+ Boolean
732
+ </SelectItem>
733
+ </SelectContent>
734
+ </Select>
735
+
736
+ <Button
737
+ variant="ghost"
738
+ size="icon"
739
+ className="h-8 w-8"
740
+ onClick={() => removeMapping(mapping.id)}
741
+ >
742
+ <Trash2 className="h-3 w-3 text-destructive" />
743
+ </Button>
744
+ </div>
745
+ ))}
746
+ </div>
747
+ )}
748
+ </div>
749
+
750
+ {/* Match Results */}
751
+ {matchStats && (
752
+ <Alert>
753
+ <Eye className="h-4 w-4" />
754
+ <AlertTitle>Match Results</AlertTitle>
755
+ <AlertDescription className="flex flex-wrap items-center gap-2">
756
+ <Badge variant="default">{matchStats.matched} matched</Badge>
757
+ <Badge variant="secondary">{matchStats.unmatched} unmatched</Badge>
758
+ <Badge variant="outline">{matchStats.highConfidence} high confidence</Badge>
759
+ {matchStats.multiMatch > 0 && (
760
+ <Badge variant="destructive">{matchStats.multiMatch} multi-match</Badge>
761
+ )}
762
+ </AlertDescription>
763
+ </Alert>
764
+ )}
765
+
766
+ {/* Import Stats */}
767
+ {importStats && (
768
+ <Alert variant={importStats.errors.length === 0 ? 'default' : 'destructive'}>
769
+ <Check className="h-4 w-4" />
770
+ <AlertTitle>Import Complete</AlertTitle>
771
+ <AlertDescription>
772
+ <div className="flex flex-wrap items-center gap-2 mt-1">
773
+ <Badge variant="default">
774
+ {importStats.mutationsCreated} properties updated
775
+ </Badge>
776
+ <Badge variant="secondary">{importStats.matchedRows} rows matched</Badge>
777
+ <Badge variant="outline">{importStats.unmatchedRows} rows unmatched</Badge>
778
+ </div>
779
+ {importStats.warnings.length > 0 && (
780
+ <div className="mt-2 text-xs text-amber-600">
781
+ {importStats.warnings.length} warning(s)
782
+ </div>
783
+ )}
784
+ </AlertDescription>
785
+ </Alert>
786
+ )}
787
+
788
+ {/* Error Display */}
789
+ {error && (
790
+ <Alert variant="destructive">
791
+ <AlertCircle className="h-4 w-4" />
792
+ <AlertTitle>Error</AlertTitle>
793
+ <AlertDescription className="whitespace-pre-wrap">{error}</AlertDescription>
794
+ </Alert>
795
+ )}
796
+ </>
797
+ )}
798
+ </div>
799
+
800
+ <DialogFooter className="gap-2">
801
+ <Button
802
+ variant="secondary"
803
+ onClick={handlePreview}
804
+ disabled={!csvConnector || !csvContent || !matchColumn || isProcessing}
805
+ >
806
+ {isProcessing ? (
807
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
808
+ ) : (
809
+ <Eye className="h-4 w-4 mr-2" />
810
+ )}
811
+ Preview Matches
812
+ </Button>
813
+ <Button
814
+ onClick={handleImport}
815
+ disabled={
816
+ !csvConnector ||
817
+ !csvContent ||
818
+ !matchResults ||
819
+ matchStats?.matched === 0 ||
820
+ mappings.length === 0 ||
821
+ isProcessing
822
+ }
823
+ >
824
+ {isProcessing ? (
825
+ <>
826
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
827
+ Importing...
828
+ </>
829
+ ) : (
830
+ <>
831
+ <Play className="h-4 w-4 mr-2" />
832
+ Import {matchStats?.matched || 0} rows
833
+ </>
834
+ )}
835
+ </Button>
836
+ </DialogFooter>
837
+ </DialogContent>
838
+ </Dialog>
839
+ );
840
+ }