@ifc-lite/viewer 1.11.4 → 1.13.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.
@@ -4,16 +4,24 @@
4
4
 
5
5
  /**
6
6
  * Export Dialog for IFC export with property mutations
7
+ *
8
+ * Schema drives the output format automatically:
9
+ * - IFC2X3 / IFC4 / IFC4X3 → .ifc (STEP)
10
+ * - IFC5 → .ifcx (JSON + USD geometry)
11
+ *
12
+ * "Changes Only" exports just mutations:
13
+ * - Below IFC5 → .json
14
+ * - IFC5 → .ifcx
7
15
  */
8
16
 
9
17
  import { useState, useCallback, useMemo, useEffect } from 'react';
10
18
  import {
11
19
  Download,
12
- FileText,
13
- FileJson,
14
20
  AlertCircle,
15
21
  Check,
16
22
  Loader2,
23
+ ArrowUp,
24
+ ArrowDown,
17
25
  } from 'lucide-react';
18
26
  import { Button } from '@/components/ui/button';
19
27
  import { Label } from '@/components/ui/label';
@@ -41,13 +49,13 @@ import {
41
49
  AlertTitle,
42
50
  } from '@/components/ui/alert';
43
51
  import { useViewerStore } from '@/store';
44
- import { StepExporter, MergedExporter, type MergeModelInput } from '@ifc-lite/export';
52
+ import { toast } from '@/components/ui/toast';
53
+ import { StepExporter, MergedExporter, Ifc5Exporter, type MergeModelInput } from '@ifc-lite/export';
45
54
  import { MutablePropertyView } from '@ifc-lite/mutations';
46
55
  import { extractPropertiesOnDemand, type IfcDataStore } from '@ifc-lite/parser';
47
56
 
48
- type ExportFormat = 'ifc' | 'ifcx' | 'json';
49
57
  type ExportScope = 'single' | 'merged';
50
- type SchemaVersion = 'IFC2X3' | 'IFC4' | 'IFC4X3';
58
+ type SchemaVersion = 'IFC2X3' | 'IFC4' | 'IFC4X3' | 'IFC5';
51
59
 
52
60
  interface ExportDialogProps {
53
61
  trigger?: React.ReactNode;
@@ -68,17 +76,19 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
68
76
  const legacyGeometryResult = useViewerStore((s) => s.geometryResult);
69
77
 
70
78
  const [open, setOpen] = useState(false);
71
- const [format, setFormat] = useState<ExportFormat>('ifc');
72
- const [schema, setSchema] = useState<SchemaVersion>('IFC4');
79
+ const [schema, setSchema] = useState<SchemaVersion | ''>('');
73
80
  const [selectedModelId, setSelectedModelId] = useState<string>('');
74
81
  const [exportScope, setExportScope] = useState<ExportScope>('single');
75
82
  const [includeGeometry, setIncludeGeometry] = useState(true);
76
83
  const [applyMutations, setApplyMutations] = useState(true);
77
- const [deltaOnly, setDeltaOnly] = useState(false);
84
+ const [changesOnly, setChangesOnly] = useState(false);
78
85
  const [visibleOnly, setVisibleOnly] = useState(false);
79
86
  const [isExporting, setIsExporting] = useState(false);
80
87
  const [exportResult, setExportResult] = useState<{ success: boolean; message: string } | null>(null);
81
88
 
89
+ // Derived: is this an IFC5/IFCX export?
90
+ const isIfc5 = schema === 'IFC5';
91
+
82
92
  // Get list of models with data stores - includes both federated models and legacy single-model
83
93
  const modelList = useMemo(() => {
84
94
  const list = Array.from(models.values()).map((m) => ({
@@ -148,6 +158,33 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
148
158
  registerMutationView(selectedModelId, mutationView);
149
159
  }, [selectedModel, selectedModelId, getMutationView, registerMutationView]);
150
160
 
161
+ // Default schema to selected model's schema version
162
+ useEffect(() => {
163
+ if (!selectedModel) return;
164
+ const modelSchema = selectedModel.schemaVersion as SchemaVersion;
165
+ if (modelSchema) {
166
+ setSchema(modelSchema);
167
+ }
168
+ }, [selectedModel?.schemaVersion]);
169
+
170
+ // Determine schema conversion direction
171
+ const sourceSchema = (selectedModel?.schemaVersion as SchemaVersion) || '';
172
+ const schemaConversion = useMemo(() => {
173
+ if (!sourceSchema || !schema) return null;
174
+ const order: Record<string, number> = { IFC2X3: 1, IFC4: 2, IFC4X3: 3, IFC5: 4 };
175
+ const src = order[sourceSchema] ?? 0;
176
+ const dst = order[schema] ?? 0;
177
+ if (src === dst) return null;
178
+ return src < dst ? 'upgrade' as const : 'downgrade' as const;
179
+ }, [sourceSchema, schema]);
180
+
181
+ // Reset scope to single when switching to IFC5 (merged not supported)
182
+ useEffect(() => {
183
+ if (isIfc5) {
184
+ setExportScope('single');
185
+ }
186
+ }, [isIfc5]);
187
+
151
188
  const modifiedCount = useMemo(() => {
152
189
  return getModifiedEntityCount();
153
190
  }, [getModifiedEntityCount]);
@@ -211,15 +248,28 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
211
248
  return localIds.size > 0 ? localIds : null;
212
249
  }, [models, isolatedEntities, isolatedEntitiesByModel]);
213
250
 
251
+ // Compute output format description for UI
252
+ const outputInfo = useMemo(() => {
253
+ if (changesOnly) {
254
+ return isIfc5
255
+ ? { ext: '.ifcx', label: 'IFCX (JSON)' }
256
+ : { ext: '.json', label: 'JSON' };
257
+ }
258
+ return isIfc5
259
+ ? { ext: '.ifcx', label: 'IFCX (JSON + USD geometry)' }
260
+ : { ext: '.ifc', label: 'IFC (STEP)' };
261
+ }, [isIfc5, changesOnly]);
262
+
214
263
  const handleExport = useCallback(async () => {
264
+ if (!schema) return;
215
265
  if (exportScope === 'single' && !selectedModel) return;
216
266
 
217
267
  setIsExporting(true);
218
268
  setExportResult(null);
219
269
 
220
270
  try {
221
- // Handle merged export of all models
222
- if (format === 'ifc' && exportScope === 'merged') {
271
+ // Handle merged export of all models (STEP only, not IFC5)
272
+ if (!isIfc5 && exportScope === 'merged' && !changesOnly) {
223
273
  const mergeInputs: MergeModelInput[] = Array.from(models.values()).map((m) => ({
224
274
  id: m.id,
225
275
  name: m.name,
@@ -258,59 +308,84 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
258
308
  document.body.removeChild(a);
259
309
  URL.revokeObjectURL(url);
260
310
 
261
- setExportResult({
262
- success: true,
263
- message: `Merged ${result.stats.modelCount} models, ${result.stats.totalEntityCount} entities`,
264
- });
311
+ const msg = `Merged ${result.stats.modelCount} models, ${result.stats.totalEntityCount} entities`;
312
+ setExportResult({ success: true, message: msg });
313
+ toast.success(msg);
265
314
  return;
266
315
  }
267
316
 
268
317
  if (!selectedModel) return;
269
318
  const mutationView = getMutationView(selectedModelId);
270
-
271
- if (format === 'ifc') {
272
- const exporter = new StepExporter(selectedModel.ifcDataStore, mutationView || undefined);
273
-
274
- // Build visibility filter for visible-only export
275
- const localHidden = visibleOnly ? getLocalHiddenIds(selectedModelId) : undefined;
276
- const localIsolated = visibleOnly ? getLocalIsolatedIds(selectedModelId) : undefined;
319
+ const baseName = selectedModel.name.replace(/\.[^.]+$/, '');
320
+
321
+ // ── IFC5 always IFCX ──────────────────────────────────────────
322
+ if (isIfc5) {
323
+ const federatedModel = models.get(selectedModelId);
324
+ const idOffset = federatedModel?.idOffset ?? 0;
325
+
326
+ const exporter = new Ifc5Exporter(
327
+ selectedModel.ifcDataStore,
328
+ selectedModel.geometryResult,
329
+ mutationView || undefined,
330
+ idOffset,
331
+ );
332
+
333
+ // When changesOnly, restrict to mutated entities and force applyMutations
334
+ let localHidden: Set<number> | undefined;
335
+ let localIsolated: Set<number> | undefined;
336
+ let effectiveVisibleOnly = visibleOnly;
337
+ let effectiveApplyMutations = applyMutations;
338
+
339
+ if (changesOnly && mutationView) {
340
+ // Compute the set of entity IDs that have mutations
341
+ const mutations = mutationView.getMutations();
342
+ const mutatedEntityIds = new Set<number>();
343
+ for (const m of mutations) {
344
+ mutatedEntityIds.add(m.entityId);
345
+ }
346
+ // Use isolatedEntityIds as an allowlist to export only mutated entities
347
+ localIsolated = mutatedEntityIds;
348
+ effectiveVisibleOnly = true;
349
+ effectiveApplyMutations = true;
350
+ } else if (visibleOnly) {
351
+ localHidden = getLocalHiddenIds(selectedModelId);
352
+ localIsolated = getLocalIsolatedIds(selectedModelId) ?? undefined;
353
+ effectiveVisibleOnly = true;
354
+ }
277
355
 
278
356
  const result = exporter.export({
279
- schema,
280
- includeGeometry,
281
- applyMutations,
282
- deltaOnly,
283
- visibleOnly,
357
+ includeGeometry: changesOnly ? false : includeGeometry,
358
+ includeProperties: true,
359
+ applyMutations: effectiveApplyMutations,
360
+ visibleOnly: effectiveVisibleOnly,
284
361
  hiddenEntityIds: localHidden,
285
362
  isolatedEntityIds: localIsolated,
286
- description: `Exported from ifc-lite with ${modifiedCount} modifications`,
287
- application: 'ifc-lite',
363
+ author: 'ifc-lite',
288
364
  });
289
365
 
290
- // Download the file
291
- const blob = new Blob([result.content], { type: 'text/plain' });
366
+ const blob = new Blob([result.content], { type: 'application/json' });
292
367
  const url = URL.createObjectURL(blob);
293
368
  const a = document.createElement('a');
294
369
  a.href = url;
295
- const suffix = visibleOnly ? '_visible' : '_modified';
296
- a.download = `${selectedModel.name.replace(/\.[^.]+$/, '')}${suffix}.ifc`;
370
+ const suffix = changesOnly ? '_changes' : (visibleOnly ? '_visible' : '_export');
371
+ a.download = `${baseName}${suffix}.ifcx`;
297
372
  document.body.appendChild(a);
298
373
  a.click();
299
374
  document.body.removeChild(a);
300
375
  URL.revokeObjectURL(url);
301
376
 
302
- setExportResult({
303
- success: true,
304
- message: `Exported ${result.stats.entityCount} entities (${result.stats.modifiedEntityCount} modified)`,
305
- });
306
- } else if (format === 'ifcx') {
307
- // Export as IFCX JSON
377
+ const ifcxMsg = `Exported IFCX: ${result.stats.nodeCount} nodes, ${result.stats.meshCount} meshes, ${result.stats.propertyCount} properties`;
378
+ setExportResult({ success: true, message: ifcxMsg });
379
+ toast.success(ifcxMsg);
380
+
381
+ // ── Changes only (pre-IFC5) JSON ───────────────────────────────
382
+ } else if (changesOnly) {
383
+ const mutations = mutationView?.getMutations() || [];
308
384
  const data = {
309
- format: 'ifcx',
385
+ version: 1,
310
386
  modelId: selectedModelId,
311
387
  modelName: selectedModel.name,
312
- schemaVersion: 'IFC5',
313
- mutations: mutationView?.getMutations() || [],
388
+ mutations,
314
389
  exportedAt: new Date().toISOString(),
315
390
  };
316
391
 
@@ -318,52 +393,58 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
318
393
  const url = URL.createObjectURL(blob);
319
394
  const a = document.createElement('a');
320
395
  a.href = url;
321
- a.download = `${selectedModel.name.replace(/\.[^.]+$/, '')}_modified.ifcx`;
396
+ a.download = `${baseName}_changes.json`;
322
397
  document.body.appendChild(a);
323
398
  a.click();
324
399
  document.body.removeChild(a);
325
400
  URL.revokeObjectURL(url);
326
401
 
327
- setExportResult({
328
- success: true,
329
- message: `Exported IFCX with ${mutationView?.getMutations().length || 0} mutations`,
330
- });
402
+ const jsonMsg = `Exported ${mutations.length} changes as JSON`;
403
+ setExportResult({ success: true, message: jsonMsg });
404
+ toast.success(jsonMsg);
405
+
406
+ // ── Pre-IFC5 full export → STEP ──────────────────────────────────
331
407
  } else {
332
- // Export mutations as JSON
333
- const mutations = mutationView?.getMutations() || [];
334
- const data = {
335
- version: 1,
336
- modelId: selectedModelId,
337
- modelName: selectedModel.name,
338
- mutations,
339
- exportedAt: new Date().toISOString(),
340
- };
408
+ const exporter = new StepExporter(selectedModel.ifcDataStore, mutationView || undefined);
341
409
 
342
- const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
410
+ const localHidden = visibleOnly ? getLocalHiddenIds(selectedModelId) : undefined;
411
+ const localIsolated = visibleOnly ? getLocalIsolatedIds(selectedModelId) : undefined;
412
+
413
+ const result = exporter.export({
414
+ schema,
415
+ includeGeometry,
416
+ applyMutations,
417
+ visibleOnly,
418
+ hiddenEntityIds: localHidden,
419
+ isolatedEntityIds: localIsolated,
420
+ description: `Exported from ifc-lite with ${modifiedCount} modifications`,
421
+ application: 'ifc-lite',
422
+ });
423
+
424
+ const blob = new Blob([result.content], { type: 'text/plain' });
343
425
  const url = URL.createObjectURL(blob);
344
426
  const a = document.createElement('a');
345
427
  a.href = url;
346
- a.download = `${selectedModel.name.replace(/\.[^.]+$/, '')}_changes.json`;
428
+ const suffix = visibleOnly ? '_visible' : '_export';
429
+ a.download = `${baseName}${suffix}.ifc`;
347
430
  document.body.appendChild(a);
348
431
  a.click();
349
432
  document.body.removeChild(a);
350
433
  URL.revokeObjectURL(url);
351
434
 
352
- setExportResult({
353
- success: true,
354
- message: `Exported ${mutations.length} changes as JSON`,
355
- });
435
+ const stepMsg = `Exported ${result.stats.entityCount} entities (${result.stats.modifiedEntityCount} modified)`;
436
+ setExportResult({ success: true, message: stepMsg });
437
+ toast.success(stepMsg);
356
438
  }
357
439
  } catch (error) {
358
440
  console.error('Export failed:', error);
359
- setExportResult({
360
- success: false,
361
- message: `Export failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
362
- });
441
+ const errMsg = `Export failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
442
+ setExportResult({ success: false, message: errMsg });
443
+ toast.error(errMsg);
363
444
  } finally {
364
445
  setIsExporting(false);
365
446
  }
366
- }, [selectedModel, selectedModelId, format, schema, exportScope, includeGeometry, applyMutations, deltaOnly, visibleOnly, getMutationView, getLocalHiddenIds, getLocalIsolatedIds, modifiedCount, models]);
447
+ }, [selectedModel, selectedModelId, schema, isIfc5, exportScope, includeGeometry, applyMutations, changesOnly, visibleOnly, getMutationView, getLocalHiddenIds, getLocalIsolatedIds, modifiedCount, models]);
367
448
 
368
449
  return (
369
450
  <Dialog open={open} onOpenChange={setOpen}>
@@ -387,8 +468,8 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
387
468
  </DialogHeader>
388
469
 
389
470
  <div className="grid gap-4 py-4">
390
- {/* Export scope selector (only when multiple models loaded) */}
391
- {format === 'ifc' && modelList.length > 1 && (
471
+ {/* Scope selector (only for STEP schemas with multiple models) */}
472
+ {!isIfc5 && !changesOnly && modelList.length > 1 && (
392
473
  <div className="flex items-center gap-4">
393
474
  <Label className="w-32">Scope</Label>
394
475
  <Select value={exportScope} onValueChange={(v) => setExportScope(v as ExportScope)}>
@@ -417,7 +498,7 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
417
498
  const displayName = m.name.length > maxLen ? m.name.slice(0, maxLen) + '\u2026' : m.name;
418
499
  return (
419
500
  <SelectItem key={m.id} value={m.id} title={m.name}>
420
- {displayName}{m.isDirty ? ' *' : ''}
501
+ {displayName}{m.isDirty ? ' *' : ''}{m.schemaVersion ? ` (${m.schemaVersion})` : ''}
421
502
  </SelectItem>
422
503
  );
423
504
  })}
@@ -426,80 +507,84 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
426
507
  </div>
427
508
  )}
428
509
 
429
- {/* Format selector */}
510
+ {/* Schema selector — this drives the output format */}
430
511
  <div className="flex items-center gap-4">
431
- <Label className="w-32">Format</Label>
432
- <Select value={format} onValueChange={(v) => setFormat(v as ExportFormat)}>
512
+ <Label className="w-32">Schema</Label>
513
+ <Select value={schema} onValueChange={(v) => setSchema(v as SchemaVersion)}>
433
514
  <SelectTrigger>
434
515
  <SelectValue />
435
516
  </SelectTrigger>
436
517
  <SelectContent>
437
- <SelectItem value="ifc">
438
- <div className="flex items-center gap-2">
439
- <FileText className="h-4 w-4" />
440
- IFC (STEP)
441
- </div>
442
- </SelectItem>
443
- <SelectItem value="ifcx">
444
- <div className="flex items-center gap-2">
445
- <FileJson className="h-4 w-4" />
446
- IFCX (JSON)
447
- </div>
448
- </SelectItem>
449
- <SelectItem value="json">
450
- <div className="flex items-center gap-2">
451
- <FileJson className="h-4 w-4" />
452
- Changes Only (JSON)
453
- </div>
454
- </SelectItem>
518
+ {(['IFC2X3', 'IFC4', 'IFC4X3', 'IFC5'] as const).map((v) => (
519
+ <SelectItem key={v} value={v}>
520
+ {v === 'IFC5' ? 'IFC5 (Alpha)' : v}
521
+ {v === sourceSchema ? ' (current)' : ''}
522
+ </SelectItem>
523
+ ))}
455
524
  </SelectContent>
456
525
  </Select>
457
526
  </div>
458
527
 
459
- {/* Schema version (for IFC format) */}
460
- {format === 'ifc' && (
461
- <div className="flex items-center gap-4">
462
- <Label className="w-32">Schema</Label>
463
- <Select value={schema} onValueChange={(v) => setSchema(v as SchemaVersion)}>
464
- <SelectTrigger>
465
- <SelectValue />
466
- </SelectTrigger>
467
- <SelectContent>
468
- <SelectItem value="IFC2X3">IFC2X3</SelectItem>
469
- <SelectItem value="IFC4">IFC4</SelectItem>
470
- <SelectItem value="IFC4X3">IFC4X3</SelectItem>
471
- </SelectContent>
472
- </Select>
473
- </div>
528
+ {/* Schema conversion warning */}
529
+ {schemaConversion && (
530
+ <Alert variant={schemaConversion === 'downgrade' ? 'destructive' : 'default'}>
531
+ {schemaConversion === 'upgrade' ? (
532
+ <ArrowUp className="h-4 w-4" />
533
+ ) : (
534
+ <ArrowDown className="h-4 w-4" />
535
+ )}
536
+ <AlertTitle>
537
+ Schema {schemaConversion === 'upgrade' ? 'Upgrade' : 'Downgrade'}
538
+ </AlertTitle>
539
+ <AlertDescription>
540
+ Converting from {sourceSchema} to {schema}.
541
+ {schemaConversion === 'downgrade'
542
+ ? ' Some data may be lost in the conversion to an older schema.'
543
+ : ' Entity types will be mapped to the newer schema.'}
544
+ </AlertDescription>
545
+ </Alert>
474
546
  )}
475
547
 
548
+ {/* Output format indicator */}
549
+ <div className="flex items-center gap-4">
550
+ <Label className="w-32 text-muted-foreground">Output</Label>
551
+ <Badge variant="secondary">{outputInfo.label}</Badge>
552
+ <span className="text-xs text-muted-foreground">{outputInfo.ext}</span>
553
+ </div>
554
+
476
555
  {/* Options */}
477
- {format === 'ifc' && (
478
- <>
479
- <div className="flex items-center justify-between">
480
- <div>
481
- <Label>Export Visible Only</Label>
482
- <p className="text-xs text-muted-foreground">Only include entities currently visible in the 3D view</p>
483
- </div>
484
- <Switch checked={visibleOnly} onCheckedChange={setVisibleOnly} />
485
- </div>
486
- {exportScope === 'single' && (
487
- <>
488
- <div className="flex items-center justify-between">
489
- <Label>Include Geometry</Label>
490
- <Switch checked={includeGeometry} onCheckedChange={setIncludeGeometry} />
491
- </div>
492
- <div className="flex items-center justify-between">
493
- <Label>Apply Property Changes</Label>
494
- <Switch checked={applyMutations} onCheckedChange={setApplyMutations} />
495
- </div>
496
- <div className="flex items-center justify-between">
497
- <Label>Export Changes Only (Delta)</Label>
498
- <Switch checked={deltaOnly} onCheckedChange={setDeltaOnly} />
556
+ <div className="flex items-center justify-between">
557
+ <div>
558
+ <Label>Export Visible Only</Label>
559
+ <p className="text-xs text-muted-foreground">Only include entities currently visible in the 3D view</p>
560
+ </div>
561
+ <Switch checked={visibleOnly} onCheckedChange={setVisibleOnly} />
562
+ </div>
563
+
564
+ {!changesOnly && exportScope === 'single' && (
565
+ <div className="flex items-center justify-between">
566
+ <Label>Include Geometry</Label>
567
+ <Switch checked={includeGeometry} onCheckedChange={setIncludeGeometry} />
568
+ </div>
569
+ )}
570
+
571
+ {exportScope === 'single' && (
572
+ <div className="flex items-center justify-between">
573
+ <Label>Apply Property Changes</Label>
574
+ <Switch checked={applyMutations} onCheckedChange={setApplyMutations} />
575
+ </div>
576
+ )}
577
+
578
+ {exportScope === 'single' && (
579
+ <div className="flex items-center justify-between">
580
+ <div>
581
+ <Label>Changes Only</Label>
582
+ <p className="text-xs text-muted-foreground">
583
+ {isIfc5 ? 'Export as IFCX overlay with mutations only' : 'Export mutations as JSON delta'}
584
+ </p>
499
585
  </div>
500
- </>
501
- )}
502
- </>
586
+ <Switch checked={changesOnly} onCheckedChange={setChangesOnly} />
587
+ </div>
503
588
  )}
504
589
 
505
590
  {/* Stats */}
@@ -531,7 +616,7 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
531
616
  <Button variant="outline" onClick={() => setOpen(false)}>
532
617
  Cancel
533
618
  </Button>
534
- <Button onClick={handleExport} disabled={isExporting || !selectedModel}>
619
+ <Button onClick={handleExport} disabled={isExporting || !selectedModel || !schema}>
535
620
  {isExporting ? (
536
621
  <>
537
622
  <Loader2 className="h-4 w-4 mr-2 animate-spin" />
@@ -67,6 +67,7 @@ import { ExportChangesButton } from './ExportChangesButton';
67
67
  import { useFloorplanView } from '@/hooks/useFloorplanView';
68
68
  import { recordRecentFiles, cacheFileBlobs } from '@/lib/recent-files';
69
69
  import { ThemeSwitch } from './ThemeSwitch';
70
+ import { toast } from '@/components/ui/toast';
70
71
 
71
72
  type Tool = 'select' | 'pan' | 'orbit' | 'walk' | 'measure' | 'section';
72
73
 
@@ -332,7 +333,6 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
332
333
  try {
333
334
  const exporter = new GLTFExporter(geometryResult);
334
335
  const glb = exporter.exportGLB({ includeMetadata: true });
335
- // Create a new Uint8Array from the buffer to ensure correct typing
336
336
  const blob = new Blob([new Uint8Array(glb)], { type: 'model/gltf-binary' });
337
337
  const url = URL.createObjectURL(blob);
338
338
  const a = document.createElement('a');
@@ -340,8 +340,10 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
340
340
  a.download = 'model.glb';
341
341
  a.click();
342
342
  URL.revokeObjectURL(url);
343
+ toast.success(`Exported GLB (${(blob.size / 1024).toFixed(0)} KB)`);
343
344
  } catch (err) {
344
345
  console.error('Export failed:', err);
346
+ toast.error(`GLB export failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
345
347
  }
346
348
  }, [geometryResult]);
347
349
 
@@ -354,8 +356,10 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
354
356
  a.href = dataUrl;
355
357
  a.download = 'screenshot.png';
356
358
  a.click();
359
+ toast.success('Screenshot saved');
357
360
  } catch (err) {
358
361
  console.error('Screenshot failed:', err);
362
+ toast.error('Screenshot failed');
359
363
  }
360
364
  }, []);
361
365
 
@@ -392,15 +396,16 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
392
396
  a.download = filename;
393
397
  a.click();
394
398
  URL.revokeObjectURL(url);
399
+ toast.success(`Exported ${type} CSV`);
395
400
  } catch (err) {
396
401
  console.error('CSV export failed:', err);
402
+ toast.error(`CSV export failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
397
403
  }
398
404
  }, [ifcDataStore]);
399
405
 
400
406
  const handleExportJSON = useCallback(() => {
401
407
  if (!ifcDataStore) return;
402
408
  try {
403
- // Export basic JSON structure of entities
404
409
  const entities: Record<string, unknown>[] = [];
405
410
  for (let i = 0; i < ifcDataStore.entities.count; i++) {
406
411
  const id = ifcDataStore.entities.expressId[i];
@@ -421,8 +426,10 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
421
426
  a.download = 'model-data.json';
422
427
  a.click();
423
428
  URL.revokeObjectURL(url);
429
+ toast.success(`Exported ${entities.length} entities as JSON`);
424
430
  } catch (err) {
425
431
  console.error('JSON export failed:', err);
432
+ toast.error(`JSON export failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
426
433
  }
427
434
  }, [ifcDataStore]);
428
435