@ifc-lite/viewer 1.1.6 → 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,195 @@
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
+ * Dedicated button for exporting IFC with property mutations applied.
7
+ * Shows when there are pending changes and provides one-click export.
8
+ */
9
+
10
+ import { useState, useCallback, useMemo, useEffect } from 'react';
11
+ import { Download, Loader2, Check, AlertCircle } from 'lucide-react';
12
+ import { Button } from '@/components/ui/button';
13
+ import { Badge } from '@/components/ui/badge';
14
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
15
+ import { useViewerStore } from '@/store';
16
+ import { StepExporter } from '@ifc-lite/export';
17
+ import { MutablePropertyView } from '@ifc-lite/mutations';
18
+ import { extractPropertiesOnDemand, type IfcDataStore } from '@ifc-lite/parser';
19
+
20
+ interface ExportChangesButtonProps {
21
+ /** Optional custom class name */
22
+ className?: string;
23
+ }
24
+
25
+ export function ExportChangesButton({ className }: ExportChangesButtonProps) {
26
+ const models = useViewerStore((s) => s.models);
27
+ const getMutationView = useViewerStore((s) => s.getMutationView);
28
+ const registerMutationView = useViewerStore((s) => s.registerMutationView);
29
+ const mutationVersion = useViewerStore((s) => s.mutationVersion);
30
+
31
+ // Legacy single-model support
32
+ const legacyIfcDataStore = useViewerStore((s) => s.ifcDataStore);
33
+ const legacyGeometryResult = useViewerStore((s) => s.geometryResult);
34
+
35
+ const [isExporting, setIsExporting] = useState(false);
36
+ const [exportStatus, setExportStatus] = useState<'idle' | 'success' | 'error'>('idle');
37
+
38
+ // Get model info - supports both federated models and legacy single-model
39
+ const modelInfo = useMemo(() => {
40
+ // First check federated models
41
+ if (models.size > 0) {
42
+ const firstModel = models.values().next().value;
43
+ if (firstModel) {
44
+ return {
45
+ id: firstModel.id,
46
+ name: firstModel.name,
47
+ ifcDataStore: firstModel.ifcDataStore,
48
+ schemaVersion: firstModel.schemaVersion,
49
+ };
50
+ }
51
+ }
52
+ // Fall back to legacy single-model
53
+ if (legacyIfcDataStore && legacyGeometryResult) {
54
+ return {
55
+ id: '__legacy__',
56
+ name: 'model',
57
+ ifcDataStore: legacyIfcDataStore,
58
+ schemaVersion: legacyIfcDataStore.schemaVersion,
59
+ };
60
+ }
61
+ return null;
62
+ }, [models, legacyIfcDataStore, legacyGeometryResult]);
63
+
64
+ // Count mutations
65
+ const mutationCount = useMemo(() => {
66
+ if (!modelInfo) return 0;
67
+ const mutationView = getMutationView(modelInfo.id);
68
+ return mutationView?.getMutations().length || 0;
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [modelInfo, getMutationView, mutationVersion]);
71
+
72
+ // Ensure mutation view exists
73
+ useEffect(() => {
74
+ if (!modelInfo?.ifcDataStore) return;
75
+
76
+ let mutationView = getMutationView(modelInfo.id);
77
+ if (mutationView) return;
78
+
79
+ const dataStore = modelInfo.ifcDataStore;
80
+ mutationView = new MutablePropertyView(dataStore.properties || null, modelInfo.id);
81
+
82
+ // Set up on-demand property extraction
83
+ if (dataStore.onDemandPropertyMap && dataStore.source?.length > 0) {
84
+ mutationView.setOnDemandExtractor((entityId: number) => {
85
+ return extractPropertiesOnDemand(dataStore as IfcDataStore, entityId);
86
+ });
87
+ }
88
+
89
+ registerMutationView(modelInfo.id, mutationView);
90
+ }, [modelInfo, getMutationView, registerMutationView]);
91
+
92
+ // Format date as YYYY-MM-DD
93
+ const formatDate = useCallback(() => {
94
+ const now = new Date();
95
+ const year = now.getFullYear();
96
+ const month = String(now.getMonth() + 1).padStart(2, '0');
97
+ const day = String(now.getDate()).padStart(2, '0');
98
+ return `${year}-${month}-${day}`;
99
+ }, []);
100
+
101
+ // Generate filename from model name + date
102
+ const generateFilename = useCallback(() => {
103
+ if (!modelInfo) return 'export.ifc';
104
+ // Remove extension if present
105
+ const baseName = modelInfo.name.replace(/\.[^.]+$/, '');
106
+ return `${baseName}_${formatDate()}.ifc`;
107
+ }, [modelInfo, formatDate]);
108
+
109
+ const handleExport = useCallback(async () => {
110
+ if (!modelInfo) return;
111
+
112
+ setIsExporting(true);
113
+ setExportStatus('idle');
114
+
115
+ try {
116
+ const mutationView = getMutationView(modelInfo.id);
117
+
118
+ // Determine schema version
119
+ const schemaVersion = modelInfo.schemaVersion || 'IFC4';
120
+ const schema = schemaVersion.includes('2X3') ? 'IFC2X3'
121
+ : schemaVersion.includes('4X3') ? 'IFC4X3'
122
+ : 'IFC4';
123
+
124
+ const exporter = new StepExporter(modelInfo.ifcDataStore, mutationView || undefined);
125
+ const result = exporter.export({
126
+ schema: schema as 'IFC2X3' | 'IFC4' | 'IFC4X3',
127
+ includeGeometry: true,
128
+ applyMutations: true,
129
+ deltaOnly: false,
130
+ description: `Exported from ifc-lite with ${mutationCount} modifications`,
131
+ application: 'ifc-lite',
132
+ });
133
+
134
+ // Download the file
135
+ const blob = new Blob([result.content], { type: 'text/plain' });
136
+ const url = URL.createObjectURL(blob);
137
+ const a = document.createElement('a');
138
+ a.href = url;
139
+ a.download = generateFilename();
140
+ document.body.appendChild(a);
141
+ a.click();
142
+ document.body.removeChild(a);
143
+ URL.revokeObjectURL(url);
144
+
145
+ setExportStatus('success');
146
+
147
+ // Reset status after 2 seconds
148
+ setTimeout(() => setExportStatus('idle'), 2000);
149
+
150
+ console.log(`[ExportChangesButton] Exported ${result.stats.entityCount} entities (${result.stats.modifiedEntityCount} modified)`);
151
+ } catch (error) {
152
+ console.error('[ExportChangesButton] Export failed:', error);
153
+ setExportStatus('error');
154
+ setTimeout(() => setExportStatus('idle'), 3000);
155
+ } finally {
156
+ setIsExporting(false);
157
+ }
158
+ }, [modelInfo, getMutationView, mutationCount, generateFilename]);
159
+
160
+ // Don't render if no model or no mutations
161
+ if (!modelInfo || mutationCount === 0) {
162
+ return null;
163
+ }
164
+
165
+ return (
166
+ <Tooltip>
167
+ <TooltipTrigger asChild>
168
+ <Button
169
+ variant="outline"
170
+ size="sm"
171
+ onClick={handleExport}
172
+ disabled={isExporting}
173
+ className={className}
174
+ >
175
+ {isExporting ? (
176
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
177
+ ) : exportStatus === 'success' ? (
178
+ <Check className="h-4 w-4 mr-2 text-green-500" />
179
+ ) : exportStatus === 'error' ? (
180
+ <AlertCircle className="h-4 w-4 mr-2 text-red-500" />
181
+ ) : (
182
+ <Download className="h-4 w-4 mr-2" />
183
+ )}
184
+ Export Changes
185
+ <Badge variant="secondary" className="ml-2 text-xs">
186
+ {mutationCount}
187
+ </Badge>
188
+ </Button>
189
+ </TooltipTrigger>
190
+ <TooltipContent>
191
+ Export IFC with {mutationCount} property changes applied
192
+ </TooltipContent>
193
+ </Tooltip>
194
+ );
195
+ }
@@ -0,0 +1,402 @@
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
+ * Export Dialog for IFC export with property mutations
7
+ */
8
+
9
+ import { useState, useCallback, useMemo, useEffect } from 'react';
10
+ import {
11
+ Download,
12
+ FileText,
13
+ FileJson,
14
+ AlertCircle,
15
+ Check,
16
+ Loader2,
17
+ } from 'lucide-react';
18
+ import { Button } from '@/components/ui/button';
19
+ import { Label } from '@/components/ui/label';
20
+ import { Switch } from '@/components/ui/switch';
21
+ import { Badge } from '@/components/ui/badge';
22
+ import {
23
+ Select,
24
+ SelectContent,
25
+ SelectItem,
26
+ SelectTrigger,
27
+ SelectValue,
28
+ } from '@/components/ui/select';
29
+ import {
30
+ Dialog,
31
+ DialogContent,
32
+ DialogDescription,
33
+ DialogFooter,
34
+ DialogHeader,
35
+ DialogTitle,
36
+ DialogTrigger,
37
+ } from '@/components/ui/dialog';
38
+ import {
39
+ Alert,
40
+ AlertDescription,
41
+ AlertTitle,
42
+ } from '@/components/ui/alert';
43
+ import { useViewerStore } from '@/store';
44
+ import { StepExporter } from '@ifc-lite/export';
45
+ import { MutablePropertyView } from '@ifc-lite/mutations';
46
+ import { extractPropertiesOnDemand, type IfcDataStore } from '@ifc-lite/parser';
47
+
48
+ type ExportFormat = 'ifc' | 'ifcx' | 'json';
49
+ type SchemaVersion = 'IFC2X3' | 'IFC4' | 'IFC4X3';
50
+
51
+ interface ExportDialogProps {
52
+ trigger?: React.ReactNode;
53
+ }
54
+
55
+ export function ExportDialog({ trigger }: ExportDialogProps) {
56
+ const models = useViewerStore((s) => s.models);
57
+ const dirtyModels = useViewerStore((s) => s.dirtyModels);
58
+ const getMutationView = useViewerStore((s) => s.getMutationView);
59
+ const registerMutationView = useViewerStore((s) => s.registerMutationView);
60
+ const getModifiedEntityCount = useViewerStore((s) => s.getModifiedEntityCount);
61
+ // Also get legacy single-model state for backward compatibility
62
+ const legacyIfcDataStore = useViewerStore((s) => s.ifcDataStore);
63
+ const legacyGeometryResult = useViewerStore((s) => s.geometryResult);
64
+
65
+ const [open, setOpen] = useState(false);
66
+ const [format, setFormat] = useState<ExportFormat>('ifc');
67
+ const [schema, setSchema] = useState<SchemaVersion>('IFC4');
68
+ const [selectedModelId, setSelectedModelId] = useState<string>('');
69
+ const [includeGeometry, setIncludeGeometry] = useState(true);
70
+ const [applyMutations, setApplyMutations] = useState(true);
71
+ const [deltaOnly, setDeltaOnly] = useState(false);
72
+ const [isExporting, setIsExporting] = useState(false);
73
+ const [exportResult, setExportResult] = useState<{ success: boolean; message: string } | null>(null);
74
+
75
+ // Get list of models with data stores - includes both federated models and legacy single-model
76
+ const modelList = useMemo(() => {
77
+ const list = Array.from(models.values()).map((m) => ({
78
+ id: m.id,
79
+ name: m.name,
80
+ isDirty: dirtyModels.has(m.id),
81
+ schemaVersion: m.schemaVersion,
82
+ }));
83
+
84
+ // If no models in Map but legacy data exists, add a synthetic entry
85
+ if (list.length === 0 && legacyIfcDataStore) {
86
+ list.push({
87
+ id: '__legacy__',
88
+ name: 'Current Model',
89
+ isDirty: false,
90
+ schemaVersion: legacyIfcDataStore.schemaVersion,
91
+ });
92
+ }
93
+
94
+ return list;
95
+ }, [models, dirtyModels, legacyIfcDataStore]);
96
+
97
+ // Select first model by default
98
+ useMemo(() => {
99
+ if (modelList.length > 0 && !selectedModelId) {
100
+ setSelectedModelId(modelList[0].id);
101
+ }
102
+ }, [modelList, selectedModelId]);
103
+
104
+ // Get selected model's data - supports both federated and legacy mode
105
+ const selectedModel = useMemo(() => {
106
+ if (selectedModelId === '__legacy__' && legacyIfcDataStore && legacyGeometryResult) {
107
+ // Return a synthetic FederatedModel-like object for legacy mode
108
+ return {
109
+ id: '__legacy__',
110
+ name: 'Current Model',
111
+ ifcDataStore: legacyIfcDataStore,
112
+ geometryResult: legacyGeometryResult,
113
+ visible: true,
114
+ collapsed: false,
115
+ schemaVersion: legacyIfcDataStore.schemaVersion,
116
+ };
117
+ }
118
+ return models.get(selectedModelId);
119
+ }, [models, selectedModelId, legacyIfcDataStore, legacyGeometryResult]);
120
+
121
+ // Ensure mutation view exists for selected model
122
+ useEffect(() => {
123
+ if (!selectedModel?.ifcDataStore || !selectedModelId) return;
124
+
125
+ // Check if mutation view already exists
126
+ let mutationView = getMutationView(selectedModelId);
127
+ if (mutationView) return;
128
+
129
+ // Create new mutation view with on-demand property extractor
130
+ const dataStore = selectedModel.ifcDataStore;
131
+ mutationView = new MutablePropertyView(dataStore.properties || null, selectedModelId);
132
+
133
+ // Set up on-demand property extraction if the data store supports it
134
+ if (dataStore.onDemandPropertyMap && dataStore.source?.length > 0) {
135
+ mutationView.setOnDemandExtractor((entityId: number) => {
136
+ return extractPropertiesOnDemand(dataStore as IfcDataStore, entityId);
137
+ });
138
+ }
139
+
140
+ // Register the mutation view
141
+ registerMutationView(selectedModelId, mutationView);
142
+ }, [selectedModel, selectedModelId, getMutationView, registerMutationView]);
143
+
144
+ const modifiedCount = useMemo(() => {
145
+ return getModifiedEntityCount();
146
+ }, [getModifiedEntityCount]);
147
+
148
+ const handleExport = useCallback(async () => {
149
+ if (!selectedModel) return;
150
+
151
+ setIsExporting(true);
152
+ setExportResult(null);
153
+
154
+ try {
155
+ const mutationView = getMutationView(selectedModelId);
156
+
157
+ if (format === 'ifc') {
158
+ const exporter = new StepExporter(selectedModel.ifcDataStore, mutationView || undefined);
159
+ const result = exporter.export({
160
+ schema,
161
+ includeGeometry,
162
+ applyMutations,
163
+ deltaOnly,
164
+ description: `Exported from ifc-lite with ${modifiedCount} modifications`,
165
+ application: 'ifc-lite',
166
+ });
167
+
168
+ // Download the file
169
+ const blob = new Blob([result.content], { type: 'text/plain' });
170
+ const url = URL.createObjectURL(blob);
171
+ const a = document.createElement('a');
172
+ a.href = url;
173
+ a.download = `${selectedModel.name.replace(/\.[^.]+$/, '')}_modified.ifc`;
174
+ document.body.appendChild(a);
175
+ a.click();
176
+ document.body.removeChild(a);
177
+ URL.revokeObjectURL(url);
178
+
179
+ setExportResult({
180
+ success: true,
181
+ message: `Exported ${result.stats.entityCount} entities (${result.stats.modifiedEntityCount} modified)`,
182
+ });
183
+ } else if (format === 'ifcx') {
184
+ // Export as IFCX JSON
185
+ const data = {
186
+ format: 'ifcx',
187
+ modelId: selectedModelId,
188
+ modelName: selectedModel.name,
189
+ schemaVersion: 'IFC5',
190
+ mutations: mutationView?.getMutations() || [],
191
+ exportedAt: new Date().toISOString(),
192
+ };
193
+
194
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
195
+ const url = URL.createObjectURL(blob);
196
+ const a = document.createElement('a');
197
+ a.href = url;
198
+ a.download = `${selectedModel.name.replace(/\.[^.]+$/, '')}_modified.ifcx`;
199
+ document.body.appendChild(a);
200
+ a.click();
201
+ document.body.removeChild(a);
202
+ URL.revokeObjectURL(url);
203
+
204
+ setExportResult({
205
+ success: true,
206
+ message: `Exported IFCX with ${mutationView?.getMutations().length || 0} mutations`,
207
+ });
208
+ } else {
209
+ // Export mutations as JSON
210
+ const mutations = mutationView?.getMutations() || [];
211
+ const data = {
212
+ version: 1,
213
+ modelId: selectedModelId,
214
+ modelName: selectedModel.name,
215
+ mutations,
216
+ exportedAt: new Date().toISOString(),
217
+ };
218
+
219
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
220
+ const url = URL.createObjectURL(blob);
221
+ const a = document.createElement('a');
222
+ a.href = url;
223
+ a.download = `${selectedModel.name.replace(/\.[^.]+$/, '')}_changes.json`;
224
+ document.body.appendChild(a);
225
+ a.click();
226
+ document.body.removeChild(a);
227
+ URL.revokeObjectURL(url);
228
+
229
+ setExportResult({
230
+ success: true,
231
+ message: `Exported ${mutations.length} changes as JSON`,
232
+ });
233
+ }
234
+ } catch (error) {
235
+ console.error('Export failed:', error);
236
+ setExportResult({
237
+ success: false,
238
+ message: `Export failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
239
+ });
240
+ } finally {
241
+ setIsExporting(false);
242
+ }
243
+ }, [selectedModel, selectedModelId, format, schema, includeGeometry, applyMutations, deltaOnly, getMutationView, modifiedCount]);
244
+
245
+ return (
246
+ <Dialog open={open} onOpenChange={setOpen}>
247
+ <DialogTrigger asChild>
248
+ {trigger || (
249
+ <Button variant="outline" size="sm">
250
+ <Download className="h-4 w-4 mr-2" />
251
+ Export IFC
252
+ </Button>
253
+ )}
254
+ </DialogTrigger>
255
+ <DialogContent className="sm:max-w-lg">
256
+ <DialogHeader>
257
+ <DialogTitle className="flex items-center gap-2">
258
+ <Download className="h-5 w-5" />
259
+ Export IFC File
260
+ </DialogTitle>
261
+ <DialogDescription>
262
+ Export your model with property modifications applied
263
+ </DialogDescription>
264
+ </DialogHeader>
265
+
266
+ <div className="grid gap-4 py-4">
267
+ {/* Model selector */}
268
+ <div className="flex items-center gap-4">
269
+ <Label className="w-32">Model</Label>
270
+ <Select value={selectedModelId} onValueChange={setSelectedModelId}>
271
+ <SelectTrigger>
272
+ <SelectValue placeholder="Select model" />
273
+ </SelectTrigger>
274
+ <SelectContent>
275
+ {modelList.map((m) => (
276
+ <SelectItem key={m.id} value={m.id}>
277
+ <div className="flex items-center gap-2">
278
+ {m.name}
279
+ {m.isDirty && (
280
+ <Badge variant="secondary" className="text-xs">
281
+ modified
282
+ </Badge>
283
+ )}
284
+ </div>
285
+ </SelectItem>
286
+ ))}
287
+ </SelectContent>
288
+ </Select>
289
+ </div>
290
+
291
+ {/* Format selector */}
292
+ <div className="flex items-center gap-4">
293
+ <Label className="w-32">Format</Label>
294
+ <Select value={format} onValueChange={(v) => setFormat(v as ExportFormat)}>
295
+ <SelectTrigger>
296
+ <SelectValue />
297
+ </SelectTrigger>
298
+ <SelectContent>
299
+ <SelectItem value="ifc">
300
+ <div className="flex items-center gap-2">
301
+ <FileText className="h-4 w-4" />
302
+ IFC (STEP)
303
+ </div>
304
+ </SelectItem>
305
+ <SelectItem value="ifcx">
306
+ <div className="flex items-center gap-2">
307
+ <FileJson className="h-4 w-4" />
308
+ IFCX (JSON)
309
+ </div>
310
+ </SelectItem>
311
+ <SelectItem value="json">
312
+ <div className="flex items-center gap-2">
313
+ <FileJson className="h-4 w-4" />
314
+ Changes Only (JSON)
315
+ </div>
316
+ </SelectItem>
317
+ </SelectContent>
318
+ </Select>
319
+ </div>
320
+
321
+ {/* Schema version (for IFC format) */}
322
+ {format === 'ifc' && (
323
+ <div className="flex items-center gap-4">
324
+ <Label className="w-32">Schema</Label>
325
+ <Select value={schema} onValueChange={(v) => setSchema(v as SchemaVersion)}>
326
+ <SelectTrigger>
327
+ <SelectValue />
328
+ </SelectTrigger>
329
+ <SelectContent>
330
+ <SelectItem value="IFC2X3">IFC2X3</SelectItem>
331
+ <SelectItem value="IFC4">IFC4</SelectItem>
332
+ <SelectItem value="IFC4X3">IFC4X3</SelectItem>
333
+ </SelectContent>
334
+ </Select>
335
+ </div>
336
+ )}
337
+
338
+ {/* Options */}
339
+ {format === 'ifc' && (
340
+ <>
341
+ <div className="flex items-center justify-between">
342
+ <Label>Include Geometry</Label>
343
+ <Switch checked={includeGeometry} onCheckedChange={setIncludeGeometry} />
344
+ </div>
345
+ <div className="flex items-center justify-between">
346
+ <Label>Apply Property Changes</Label>
347
+ <Switch checked={applyMutations} onCheckedChange={setApplyMutations} />
348
+ </div>
349
+ <div className="flex items-center justify-between">
350
+ <Label>Export Changes Only (Delta)</Label>
351
+ <Switch checked={deltaOnly} onCheckedChange={setDeltaOnly} />
352
+ </div>
353
+ </>
354
+ )}
355
+
356
+ {/* Stats */}
357
+ {modifiedCount > 0 && (
358
+ <Alert>
359
+ <AlertCircle className="h-4 w-4" />
360
+ <AlertTitle>Pending Changes</AlertTitle>
361
+ <AlertDescription>
362
+ {modifiedCount} entities have been modified
363
+ </AlertDescription>
364
+ </Alert>
365
+ )}
366
+
367
+ {/* Export result */}
368
+ {exportResult && (
369
+ <Alert variant={exportResult.success ? 'default' : 'destructive'}>
370
+ {exportResult.success ? (
371
+ <Check className="h-4 w-4" />
372
+ ) : (
373
+ <AlertCircle className="h-4 w-4" />
374
+ )}
375
+ <AlertTitle>{exportResult.success ? 'Success' : 'Error'}</AlertTitle>
376
+ <AlertDescription>{exportResult.message}</AlertDescription>
377
+ </Alert>
378
+ )}
379
+ </div>
380
+
381
+ <DialogFooter>
382
+ <Button variant="outline" onClick={() => setOpen(false)}>
383
+ Cancel
384
+ </Button>
385
+ <Button onClick={handleExport} disabled={isExporting || !selectedModel}>
386
+ {isExporting ? (
387
+ <>
388
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
389
+ Exporting...
390
+ </>
391
+ ) : (
392
+ <>
393
+ <Download className="h-4 w-4 mr-2" />
394
+ Export
395
+ </>
396
+ )}
397
+ </Button>
398
+ </DialogFooter>
399
+ </DialogContent>
400
+ </Dialog>
401
+ );
402
+ }