@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.
Files changed (95) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
  3. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  4. package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
  5. package/dist/assets/index-yTqs8kgX.css +1 -0
  6. package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
  7. package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/package.json +18 -15
  10. package/src/components/viewer/BCFPanel.tsx +7 -789
  11. package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
  12. package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
  13. package/src/components/viewer/HierarchyPanel.tsx +110 -842
  14. package/src/components/viewer/IDSExportDialog.tsx +281 -0
  15. package/src/components/viewer/IDSPanel.tsx +126 -17
  16. package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
  17. package/src/components/viewer/LensPanel.tsx +603 -0
  18. package/src/components/viewer/MainToolbar.tsx +188 -21
  19. package/src/components/viewer/PropertiesPanel.tsx +171 -663
  20. package/src/components/viewer/PropertyEditor.tsx +866 -77
  21. package/src/components/viewer/Section2DPanel.tsx +76 -2648
  22. package/src/components/viewer/ToolOverlays.tsx +3 -1097
  23. package/src/components/viewer/ViewerLayout.tsx +132 -45
  24. package/src/components/viewer/Viewport.tsx +237 -1659
  25. package/src/components/viewer/ViewportContainer.tsx +11 -3
  26. package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
  27. package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
  28. package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
  29. package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
  30. package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
  31. package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
  32. package/src/components/viewer/hierarchy/types.ts +54 -0
  33. package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
  34. package/src/components/viewer/lists/ListBuilder.tsx +486 -0
  35. package/src/components/viewer/lists/ListPanel.tsx +540 -0
  36. package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
  37. package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
  38. package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
  39. package/src/components/viewer/properties/DocumentCard.tsx +89 -0
  40. package/src/components/viewer/properties/MaterialCard.tsx +201 -0
  41. package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
  42. package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
  43. package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
  44. package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
  45. package/src/components/viewer/properties/encodingUtils.ts +29 -0
  46. package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
  47. package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
  48. package/src/components/viewer/tools/SectionPanel.tsx +183 -0
  49. package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
  50. package/src/components/viewer/tools/formatDistance.ts +18 -0
  51. package/src/components/viewer/tools/sectionConstants.ts +14 -0
  52. package/src/components/viewer/useAnimationLoop.ts +166 -0
  53. package/src/components/viewer/useGeometryStreaming.ts +398 -0
  54. package/src/components/viewer/useKeyboardControls.ts +221 -0
  55. package/src/components/viewer/useMouseControls.ts +1009 -0
  56. package/src/components/viewer/useRenderUpdates.ts +165 -0
  57. package/src/components/viewer/useTouchControls.ts +245 -0
  58. package/src/hooks/ids/idsColorSystem.ts +125 -0
  59. package/src/hooks/ids/idsDataAccessor.ts +237 -0
  60. package/src/hooks/ids/idsExportService.ts +444 -0
  61. package/src/hooks/useBCF.ts +7 -0
  62. package/src/hooks/useDrawingExport.ts +627 -0
  63. package/src/hooks/useDrawingGeneration.ts +627 -0
  64. package/src/hooks/useFloorplanView.ts +108 -0
  65. package/src/hooks/useIDS.ts +270 -463
  66. package/src/hooks/useIfc.ts +26 -1628
  67. package/src/hooks/useIfcFederation.ts +803 -0
  68. package/src/hooks/useIfcLoader.ts +508 -0
  69. package/src/hooks/useIfcServer.ts +465 -0
  70. package/src/hooks/useKeyboardShortcuts.ts +1 -1
  71. package/src/hooks/useLens.ts +129 -0
  72. package/src/hooks/useMeasure2D.ts +365 -0
  73. package/src/hooks/useViewControls.ts +218 -0
  74. package/src/lib/ifc4-pset-definitions.test.ts +161 -0
  75. package/src/lib/ifc4-pset-definitions.ts +621 -0
  76. package/src/lib/ifc4-qto-definitions.ts +315 -0
  77. package/src/lib/lens/adapter.ts +138 -0
  78. package/src/lib/lens/index.ts +5 -0
  79. package/src/lib/lists/adapter.ts +69 -0
  80. package/src/lib/lists/index.ts +28 -0
  81. package/src/lib/lists/persistence.ts +64 -0
  82. package/src/services/fs-cache.ts +1 -1
  83. package/src/services/tauri-modules.d.ts +25 -0
  84. package/src/store/index.ts +38 -2
  85. package/src/store/slices/cameraSlice.ts +14 -1
  86. package/src/store/slices/dataSlice.ts +14 -1
  87. package/src/store/slices/lensSlice.ts +184 -0
  88. package/src/store/slices/listSlice.ts +74 -0
  89. package/src/store/slices/pinboardSlice.ts +114 -0
  90. package/src/store/types.ts +5 -0
  91. package/src/utils/ifcConfig.ts +16 -3
  92. package/src/utils/serverDataModel.ts +64 -101
  93. package/src/vite-env.d.ts +3 -0
  94. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  95. package/dist/assets/index-v3mcCUPN.css +0 -1
@@ -4,12 +4,11 @@
4
4
 
5
5
  /**
6
6
  * Property Editor component for editing IFC property values inline.
7
- * Production-ready with keyboard support and proper UX.
7
+ * Includes schema-aware property addition with IFC4 standard validation.
8
8
  */
9
9
 
10
10
  import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
11
11
  import {
12
- Save,
13
12
  X,
14
13
  Plus,
15
14
  Trash2,
@@ -17,6 +16,10 @@ import {
17
16
  Undo,
18
17
  Redo,
19
18
  Check,
19
+ BookOpen,
20
+ Tag,
21
+ Layers,
22
+ Ruler,
20
23
  } from 'lucide-react';
21
24
  import { Button } from '@/components/ui/button';
22
25
  import { Input } from '@/components/ui/input';
@@ -39,9 +42,24 @@ import {
39
42
  } from '@/components/ui/dialog';
40
43
  import { Switch } from '@/components/ui/switch';
41
44
  import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
45
+ import { Badge } from '@/components/ui/badge';
42
46
  import { useViewerStore } from '@/store';
43
- import { PropertyValueType } from '@ifc-lite/data';
47
+ import { PropertyValueType, QuantityType } from '@ifc-lite/data';
44
48
  import type { PropertyValue } from '@ifc-lite/mutations';
49
+ import {
50
+ getPsetDefinitionsForType,
51
+ getPropertiesForPset,
52
+ CLASSIFICATION_SYSTEMS,
53
+ type PsetPropertyDef,
54
+ type PsetDefinition,
55
+ } from '@/lib/ifc4-pset-definitions';
56
+ import {
57
+ getQtoDefinitionsForType,
58
+ getQuantitiesForQto,
59
+ getQuantityUnit,
60
+ type QtoQuantityDef,
61
+ type QtoDefinition,
62
+ } from '@/lib/ifc4-qto-definitions';
45
63
 
46
64
  interface PropertyEditorProps {
47
65
  modelId: string;
@@ -258,42 +276,84 @@ export function PropertyEditor({
258
276
  );
259
277
  }
260
278
 
279
+ // ============================================================================
280
+ // Schema-Aware Property Dialog
281
+ // ============================================================================
282
+
261
283
  interface NewPropertyDialogProps {
262
284
  modelId: string;
263
285
  entityId: number;
286
+ entityType: string;
264
287
  existingPsets: string[];
288
+ schemaVersion?: string;
265
289
  }
266
290
 
267
291
  /**
268
- * Dialog for adding a new property
292
+ * Schema-aware dialog for adding new properties.
293
+ * Filters available property sets based on IFC entity type.
294
+ * Shows property suggestions with correct types from IFC4 standard.
269
295
  */
270
- export function NewPropertyDialog({ modelId, entityId, existingPsets }: NewPropertyDialogProps) {
296
+ export function NewPropertyDialog({ modelId, entityId, entityType, existingPsets, schemaVersion }: NewPropertyDialogProps) {
271
297
  const setProperty = useViewerStore((s) => s.setProperty);
272
298
  const createPropertySet = useViewerStore((s) => s.createPropertySet);
273
299
  const bumpMutationVersion = useViewerStore((s) => s.bumpMutationVersion);
274
300
 
275
301
  const [open, setOpen] = useState(false);
276
302
  const [psetName, setPsetName] = useState('');
277
- const [isNewPset, setIsNewPset] = useState(false);
303
+ const [isCustomPset, setIsCustomPset] = useState(false);
304
+ const [customPsetName, setCustomPsetName] = useState('');
278
305
  const [propName, setPropName] = useState('');
306
+ const [customPropName, setCustomPropName] = useState('');
279
307
  const [value, setValue] = useState('');
280
308
  const [valueType, setValueType] = useState<PropertyValueType>(PropertyValueType.String);
281
309
 
282
- const commonPsets = useMemo(() => [
283
- 'Pset_WallCommon',
284
- 'Pset_DoorCommon',
285
- 'Pset_WindowCommon',
286
- 'Pset_SlabCommon',
287
- 'Pset_BeamCommon',
288
- 'Pset_ColumnCommon',
289
- 'Pset_BuildingElementProxyCommon',
290
- 'Pset_SpaceCommon',
291
- ], []);
310
+ // Get schema-valid property sets for this entity type
311
+ const validPsetDefs = useMemo(() => {
312
+ return getPsetDefinitionsForType(entityType, schemaVersion);
313
+ }, [entityType, schemaVersion]);
314
+
315
+ // Split into: already on entity vs available to add
316
+ const { existingStandardPsets, availableStandardPsets } = useMemo(() => {
317
+ const existing: PsetDefinition[] = [];
318
+ const available: PsetDefinition[] = [];
319
+ for (const def of validPsetDefs) {
320
+ if (existingPsets.includes(def.name)) {
321
+ existing.push(def);
322
+ } else {
323
+ available.push(def);
324
+ }
325
+ }
326
+ return { existingStandardPsets: existing, availableStandardPsets: available };
327
+ }, [validPsetDefs, existingPsets]);
328
+
329
+ // Get property suggestions for selected pset
330
+ const propertySuggestions = useMemo((): PsetPropertyDef[] => {
331
+ if (!psetName || isCustomPset) return [];
332
+ return getPropertiesForPset(psetName);
333
+ }, [psetName, isCustomPset]);
334
+
335
+ // Determine effective property name and type
336
+ const effectivePsetName = isCustomPset ? customPsetName : psetName;
337
+ const effectivePropName = propName || customPropName;
338
+
339
+ // Auto-update type when selecting a standard property
340
+ const handlePropertySelect = useCallback((name: string) => {
341
+ setPropName(name);
342
+ setCustomPropName('');
343
+ // Auto-set type from schema
344
+ const propDef = propertySuggestions.find(p => p.name === name);
345
+ if (propDef) {
346
+ setValueType(propDef.type);
347
+ // Set sensible defaults for boolean properties
348
+ if (propDef.type === PropertyValueType.Boolean) {
349
+ setValue('false');
350
+ }
351
+ }
352
+ }, [propertySuggestions]);
292
353
 
293
354
  const handleSubmit = useCallback(() => {
294
- if (!psetName || !propName) return;
355
+ if (!effectivePsetName || !effectivePropName) return;
295
356
 
296
- // Normalize model ID for legacy models
297
357
  let normalizedModelId = modelId;
298
358
  if (modelId === 'legacy') {
299
359
  normalizedModelId = '__legacy__';
@@ -301,85 +361,189 @@ export function NewPropertyDialog({ modelId, entityId, existingPsets }: NewPrope
301
361
 
302
362
  const parsedValue = parseValue(value, valueType);
303
363
 
304
- if (isNewPset) {
305
- createPropertySet(normalizedModelId, entityId, psetName, [
306
- { name: propName, value: parsedValue, type: valueType },
364
+ // Check if pset exists on entity already
365
+ const psetExists = existingPsets.includes(effectivePsetName);
366
+
367
+ if (!psetExists) {
368
+ createPropertySet(normalizedModelId, entityId, effectivePsetName, [
369
+ { name: effectivePropName, value: parsedValue, type: valueType },
307
370
  ]);
308
371
  } else {
309
- setProperty(normalizedModelId, entityId, psetName, propName, parsedValue, valueType);
372
+ setProperty(normalizedModelId, entityId, effectivePsetName, effectivePropName, parsedValue, valueType);
310
373
  }
311
374
 
312
375
  bumpMutationVersion();
313
376
 
314
377
  // Reset form
315
378
  setPsetName('');
379
+ setCustomPsetName('');
316
380
  setPropName('');
381
+ setCustomPropName('');
317
382
  setValue('');
318
383
  setValueType(PropertyValueType.String);
319
- setIsNewPset(false);
384
+ setIsCustomPset(false);
320
385
  setOpen(false);
321
- }, [modelId, entityId, psetName, propName, value, valueType, isNewPset, setProperty, createPropertySet, bumpMutationVersion]);
386
+ }, [modelId, entityId, effectivePsetName, effectivePropName, value, valueType, existingPsets, setProperty, createPropertySet, bumpMutationVersion]);
387
+
388
+ const resetForm = useCallback(() => {
389
+ setPsetName('');
390
+ setCustomPsetName('');
391
+ setPropName('');
392
+ setCustomPropName('');
393
+ setValue('');
394
+ setValueType(PropertyValueType.String);
395
+ setIsCustomPset(false);
396
+ }, []);
322
397
 
323
398
  return (
324
- <Dialog open={open} onOpenChange={setOpen}>
399
+ <Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) resetForm(); }}>
325
400
  <DialogTrigger asChild>
326
401
  <Button variant="outline" size="sm" className="h-7">
327
402
  <Plus className="h-3 w-3 mr-1" />
328
- Add Property
403
+ Property
329
404
  </Button>
330
405
  </DialogTrigger>
331
- <DialogContent className="sm:max-w-md">
406
+ <DialogContent className="sm:max-w-lg">
332
407
  <DialogHeader>
333
- <DialogTitle>Add Property</DialogTitle>
408
+ <DialogTitle className="flex items-center gap-2">
409
+ <BookOpen className="h-4 w-4" />
410
+ Add Property
411
+ </DialogTitle>
334
412
  <DialogDescription>
335
- Add a new property to this entity
413
+ Add a property to this <span className="font-mono font-medium text-zinc-700 dark:text-zinc-300">{entityType}</span> element.
414
+ {validPsetDefs.length > 0 && (
415
+ <span className="block mt-1 text-emerald-600 dark:text-emerald-400">
416
+ {schemaVersion || 'IFC4'} schema: {validPsetDefs.length} standard property set{validPsetDefs.length !== 1 ? 's' : ''} available
417
+ </span>
418
+ )}
336
419
  </DialogDescription>
337
420
  </DialogHeader>
338
421
  <div className="grid gap-4 py-4">
339
- <div className="flex items-center gap-4">
340
- <Label className="w-24">New Pset</Label>
341
- <Switch checked={isNewPset} onCheckedChange={setIsNewPset} />
342
- </div>
343
- <div className="flex items-center gap-4">
344
- <Label className="w-24">Property Set</Label>
345
- {isNewPset ? (
422
+ {/* Property Set Selection */}
423
+ <div className="space-y-2">
424
+ <div className="flex items-center justify-between">
425
+ <Label className="text-sm font-medium">Property Set</Label>
426
+ <Button
427
+ variant="ghost"
428
+ size="sm"
429
+ className="h-6 px-2 text-[10px]"
430
+ onClick={() => { setIsCustomPset(!isCustomPset); setPsetName(''); setCustomPsetName(''); setPropName(''); setCustomPropName(''); }}
431
+ >
432
+ {isCustomPset ? 'Use standard' : 'Custom name'}
433
+ </Button>
434
+ </div>
435
+ {isCustomPset ? (
346
436
  <Input
347
- value={psetName}
348
- onChange={(e) => setPsetName(e.target.value)}
349
- placeholder="e.g., Pset_CustomProperties"
437
+ value={customPsetName}
438
+ onChange={(e) => setCustomPsetName(e.target.value)}
439
+ placeholder="e.g., Pset_MyCustomProperties"
440
+ className="font-mono text-sm"
350
441
  />
351
442
  ) : (
352
- <Select value={psetName} onValueChange={setPsetName}>
353
- <SelectTrigger>
354
- <SelectValue placeholder="Select property set" />
443
+ <Select value={psetName} onValueChange={(v) => { setPsetName(v); setPropName(''); setCustomPropName(''); setValue(''); }}>
444
+ <SelectTrigger className="font-mono text-sm">
445
+ <SelectValue placeholder="Select property set..." />
355
446
  </SelectTrigger>
356
447
  <SelectContent>
357
- {existingPsets.map((name) => (
358
- <SelectItem key={name} value={name}>
359
- {name}
360
- </SelectItem>
361
- ))}
362
- {commonPsets
363
- .filter((p) => !existingPsets.includes(p))
364
- .map((name) => (
365
- <SelectItem key={name} value={name}>
366
- {name} (new)
367
- </SelectItem>
368
- ))}
448
+ {/* Existing psets on this entity */}
449
+ {existingStandardPsets.length > 0 && (
450
+ <>
451
+ <div className="px-2 py-1.5 text-[10px] font-bold uppercase tracking-wider text-zinc-400">
452
+ On this element
453
+ </div>
454
+ {existingStandardPsets.map((def) => (
455
+ <SelectItem key={def.name} value={def.name}>
456
+ <div className="flex items-center gap-2">
457
+ <span>{def.name}</span>
458
+ <Badge variant="secondary" className="h-4 px-1 text-[9px]">existing</Badge>
459
+ </div>
460
+ </SelectItem>
461
+ ))}
462
+ </>
463
+ )}
464
+ {/* Non-standard existing psets */}
465
+ {existingPsets.filter(p => !existingStandardPsets.some(d => d.name === p)).length > 0 && (
466
+ <>
467
+ <div className="px-2 py-1.5 text-[10px] font-bold uppercase tracking-wider text-zinc-400">
468
+ Existing (custom)
469
+ </div>
470
+ {existingPsets.filter(p => !existingStandardPsets.some(d => d.name === p)).map((name) => (
471
+ <SelectItem key={name} value={name}>
472
+ <span>{name}</span>
473
+ </SelectItem>
474
+ ))}
475
+ </>
476
+ )}
477
+ {/* Available standard psets for this type */}
478
+ {availableStandardPsets.length > 0 && (
479
+ <>
480
+ <div className="px-2 py-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
481
+ {schemaVersion || 'IFC4'} Standard — {entityType}
482
+ </div>
483
+ {availableStandardPsets.map((def) => (
484
+ <SelectItem key={def.name} value={def.name}>
485
+ <div className="flex flex-col">
486
+ <div className="flex items-center gap-2">
487
+ <span className="font-medium">{def.name}</span>
488
+ <Badge variant="outline" className="h-4 px-1 text-[9px] border-emerald-300 text-emerald-600">new</Badge>
489
+ </div>
490
+ <span className="text-[10px] text-zinc-400">{def.description}</span>
491
+ </div>
492
+ </SelectItem>
493
+ ))}
494
+ </>
495
+ )}
369
496
  </SelectContent>
370
497
  </Select>
371
498
  )}
372
499
  </div>
373
- <div className="flex items-center gap-4">
374
- <Label className="w-24">Property</Label>
375
- <Input
376
- value={propName}
377
- onChange={(e) => setPropName(e.target.value)}
378
- placeholder="e.g., FireRating"
379
- />
500
+
501
+ {/* Property Selection */}
502
+ <div className="space-y-2">
503
+ <Label className="text-sm font-medium">Property</Label>
504
+ {propertySuggestions.length > 0 ? (
505
+ <div className="space-y-2">
506
+ <Select value={propName} onValueChange={handlePropertySelect}>
507
+ <SelectTrigger className="font-mono text-sm">
508
+ <SelectValue placeholder="Select property..." />
509
+ </SelectTrigger>
510
+ <SelectContent>
511
+ {propertySuggestions.map((prop) => (
512
+ <SelectItem key={prop.name} value={prop.name}>
513
+ <div className="flex flex-col">
514
+ <div className="flex items-center gap-2">
515
+ <span className="font-medium">{prop.name}</span>
516
+ <Badge variant="secondary" className="h-4 px-1 text-[9px]">{getTypeName(prop.type)}</Badge>
517
+ </div>
518
+ <span className="text-[10px] text-zinc-400">{prop.description}</span>
519
+ </div>
520
+ </SelectItem>
521
+ ))}
522
+ </SelectContent>
523
+ </Select>
524
+ {/* Allow custom property name even for standard psets */}
525
+ {!propName && (
526
+ <Input
527
+ value={customPropName}
528
+ onChange={(e) => setCustomPropName(e.target.value)}
529
+ placeholder="Or type custom property name..."
530
+ className="font-mono text-sm"
531
+ />
532
+ )}
533
+ </div>
534
+ ) : (
535
+ <Input
536
+ value={customPropName}
537
+ onChange={(e) => setCustomPropName(e.target.value)}
538
+ placeholder="e.g., FireRating"
539
+ className="font-mono text-sm"
540
+ />
541
+ )}
380
542
  </div>
381
- <div className="flex items-center gap-4">
382
- <Label className="w-24">Type</Label>
543
+
544
+ {/* Type selector */}
545
+ <div className="space-y-2">
546
+ <Label className="text-sm font-medium">Type</Label>
383
547
  <Select
384
548
  value={valueType.toString()}
385
549
  onValueChange={(v) => setValueType(parseInt(v) as PropertyValueType)}
@@ -397,28 +561,34 @@ export function NewPropertyDialog({ modelId, entityId, existingPsets }: NewPrope
397
561
  </SelectContent>
398
562
  </Select>
399
563
  </div>
400
- <div className="flex items-center gap-4">
401
- <Label className="w-24">Value</Label>
564
+
565
+ {/* Value input */}
566
+ <div className="space-y-2">
567
+ <Label className="text-sm font-medium">Value</Label>
402
568
  {valueType === PropertyValueType.Boolean ? (
403
- <Switch
404
- checked={value === 'true'}
405
- onCheckedChange={(checked) => setValue(checked ? 'true' : 'false')}
406
- />
569
+ <div className="flex items-center gap-3">
570
+ <Switch
571
+ checked={value === 'true'}
572
+ onCheckedChange={(checked) => setValue(checked ? 'true' : 'false')}
573
+ />
574
+ <span className="text-sm text-zinc-500">{value === 'true' ? 'True' : 'False'}</span>
575
+ </div>
407
576
  ) : (
408
577
  <Input
409
578
  value={value}
410
579
  onChange={(e) => setValue(e.target.value)}
411
580
  placeholder="Property value"
412
581
  type={valueType === PropertyValueType.Real || valueType === PropertyValueType.Integer ? 'number' : 'text'}
582
+ className="font-mono text-sm"
413
583
  />
414
584
  )}
415
585
  </div>
416
586
  </div>
417
587
  <DialogFooter>
418
- <Button variant="outline" onClick={() => setOpen(false)}>
588
+ <Button variant="outline" onClick={() => { setOpen(false); resetForm(); }}>
419
589
  Cancel
420
590
  </Button>
421
- <Button onClick={handleSubmit} disabled={!psetName || !propName}>
591
+ <Button onClick={handleSubmit} disabled={!effectivePsetName || !effectivePropName}>
422
592
  Add Property
423
593
  </Button>
424
594
  </DialogFooter>
@@ -427,6 +597,622 @@ export function NewPropertyDialog({ modelId, entityId, existingPsets }: NewPrope
427
597
  );
428
598
  }
429
599
 
600
+ // ============================================================================
601
+ // Classification Dialog
602
+ // ============================================================================
603
+
604
+ interface AddClassificationDialogProps {
605
+ modelId: string;
606
+ entityId: number;
607
+ entityType: string;
608
+ }
609
+
610
+ /**
611
+ * Dialog for adding a classification reference to an entity.
612
+ * Supports common classification systems (Uniclass, OmniClass, MasterFormat, etc.).
613
+ * Stored as a special property set for mutation tracking.
614
+ */
615
+ export function AddClassificationDialog({ modelId, entityId, entityType }: AddClassificationDialogProps) {
616
+ const createPropertySet = useViewerStore((s) => s.createPropertySet);
617
+ const bumpMutationVersion = useViewerStore((s) => s.bumpMutationVersion);
618
+
619
+ const [open, setOpen] = useState(false);
620
+ const [system, setSystem] = useState('');
621
+ const [customSystem, setCustomSystem] = useState('');
622
+ const [identification, setIdentification] = useState('');
623
+ const [name, setName] = useState('');
624
+
625
+ const effectiveSystem = system === '__custom__' ? customSystem : system;
626
+
627
+ const handleSubmit = useCallback(() => {
628
+ if (!effectiveSystem || !identification) return;
629
+
630
+ let normalizedModelId = modelId;
631
+ if (modelId === 'legacy') {
632
+ normalizedModelId = '__legacy__';
633
+ }
634
+
635
+ // Store classification as a property set named "Classification [SystemName]"
636
+ const psetName = `Classification [${effectiveSystem}]`;
637
+ createPropertySet(normalizedModelId, entityId, psetName, [
638
+ { name: 'System', value: effectiveSystem, type: PropertyValueType.Label },
639
+ { name: 'Identification', value: identification, type: PropertyValueType.Identifier },
640
+ { name: 'Name', value: name || identification, type: PropertyValueType.Label },
641
+ ]);
642
+
643
+ bumpMutationVersion();
644
+
645
+ // Reset form
646
+ setSystem('');
647
+ setCustomSystem('');
648
+ setIdentification('');
649
+ setName('');
650
+ setOpen(false);
651
+ }, [modelId, entityId, effectiveSystem, identification, name, createPropertySet, bumpMutationVersion]);
652
+
653
+ return (
654
+ <Dialog open={open} onOpenChange={(o) => {
655
+ setOpen(o);
656
+ if (!o) { setSystem(''); setCustomSystem(''); setIdentification(''); setName(''); }
657
+ }}>
658
+ <DialogTrigger asChild>
659
+ <Button variant="outline" size="sm" className="h-7">
660
+ <Tag className="h-3 w-3 mr-1" />
661
+ Classification
662
+ </Button>
663
+ </DialogTrigger>
664
+ <DialogContent className="sm:max-w-md">
665
+ <DialogHeader>
666
+ <DialogTitle className="flex items-center gap-2">
667
+ <Tag className="h-4 w-4" />
668
+ Add Classification
669
+ </DialogTitle>
670
+ <DialogDescription>
671
+ Assign a classification reference to this <span className="font-mono font-medium text-zinc-700 dark:text-zinc-300">{entityType}</span>.
672
+ </DialogDescription>
673
+ </DialogHeader>
674
+ <div className="grid gap-4 py-4">
675
+ {/* Classification System */}
676
+ <div className="space-y-2">
677
+ <Label className="text-sm font-medium">Classification System</Label>
678
+ <Select value={system} onValueChange={setSystem}>
679
+ <SelectTrigger>
680
+ <SelectValue placeholder="Select system..." />
681
+ </SelectTrigger>
682
+ <SelectContent>
683
+ {CLASSIFICATION_SYSTEMS.map((cs) => (
684
+ <SelectItem key={cs.name} value={cs.name}>
685
+ <div className="flex flex-col">
686
+ <span className="font-medium">{cs.name}</span>
687
+ <span className="text-[10px] text-zinc-400">{cs.description}</span>
688
+ </div>
689
+ </SelectItem>
690
+ ))}
691
+ <SelectItem value="__custom__">
692
+ <span className="text-zinc-500">Custom system...</span>
693
+ </SelectItem>
694
+ </SelectContent>
695
+ </Select>
696
+ {system === '__custom__' && (
697
+ <Input
698
+ value={customSystem}
699
+ onChange={(e) => setCustomSystem(e.target.value)}
700
+ placeholder="Classification system name"
701
+ className="mt-2"
702
+ />
703
+ )}
704
+ </div>
705
+
706
+ {/* Identification (code) */}
707
+ <div className="space-y-2">
708
+ <Label className="text-sm font-medium">Identification Code</Label>
709
+ <Input
710
+ value={identification}
711
+ onChange={(e) => setIdentification(e.target.value)}
712
+ placeholder="e.g., Ss_25_10_30 or 03 30 00"
713
+ className="font-mono"
714
+ />
715
+ <p className="text-[10px] text-zinc-400">The classification code or reference number</p>
716
+ </div>
717
+
718
+ {/* Name (optional) */}
719
+ <div className="space-y-2">
720
+ <Label className="text-sm font-medium">Name (optional)</Label>
721
+ <Input
722
+ value={name}
723
+ onChange={(e) => setName(e.target.value)}
724
+ placeholder="e.g., Cast-in-place concrete walls"
725
+ />
726
+ </div>
727
+ </div>
728
+ <DialogFooter>
729
+ <Button variant="outline" onClick={() => setOpen(false)}>
730
+ Cancel
731
+ </Button>
732
+ <Button onClick={handleSubmit} disabled={!effectiveSystem || !identification}>
733
+ Add Classification
734
+ </Button>
735
+ </DialogFooter>
736
+ </DialogContent>
737
+ </Dialog>
738
+ );
739
+ }
740
+
741
+ // ============================================================================
742
+ // Material Dialog
743
+ // ============================================================================
744
+
745
+ interface AddMaterialDialogProps {
746
+ modelId: string;
747
+ entityId: number;
748
+ entityType: string;
749
+ }
750
+
751
+ /**
752
+ * Dialog for assigning a material to an entity.
753
+ * Stored as a special property set for mutation tracking.
754
+ */
755
+ export function AddMaterialDialog({ modelId, entityId, entityType }: AddMaterialDialogProps) {
756
+ const createPropertySet = useViewerStore((s) => s.createPropertySet);
757
+ const bumpMutationVersion = useViewerStore((s) => s.bumpMutationVersion);
758
+
759
+ const [open, setOpen] = useState(false);
760
+ const [materialName, setMaterialName] = useState('');
761
+ const [category, setCategory] = useState('');
762
+ const [description, setDescription] = useState('');
763
+
764
+ const handleSubmit = useCallback(() => {
765
+ if (!materialName) return;
766
+
767
+ let normalizedModelId = modelId;
768
+ if (modelId === 'legacy') {
769
+ normalizedModelId = '__legacy__';
770
+ }
771
+
772
+ // Store material as a property set named "Material"
773
+ const psetName = `Material [${materialName}]`;
774
+ const properties: Array<{ name: string; value: string; type: PropertyValueType }> = [
775
+ { name: 'Name', value: materialName, type: PropertyValueType.Label },
776
+ ];
777
+
778
+ if (category) {
779
+ properties.push({ name: 'Category', value: category, type: PropertyValueType.Label });
780
+ }
781
+ if (description) {
782
+ properties.push({ name: 'Description', value: description, type: PropertyValueType.Label });
783
+ }
784
+
785
+ createPropertySet(normalizedModelId, entityId, psetName, properties);
786
+ bumpMutationVersion();
787
+
788
+ // Reset form
789
+ setMaterialName('');
790
+ setCategory('');
791
+ setDescription('');
792
+ setOpen(false);
793
+ }, [modelId, entityId, materialName, category, description, createPropertySet, bumpMutationVersion]);
794
+
795
+ // Common material categories (module-level constant used below)
796
+ const materialCategories = MATERIAL_CATEGORIES;
797
+
798
+ return (
799
+ <Dialog open={open} onOpenChange={(o) => {
800
+ setOpen(o);
801
+ if (!o) { setMaterialName(''); setCategory(''); setDescription(''); }
802
+ }}>
803
+ <DialogTrigger asChild>
804
+ <Button variant="outline" size="sm" className="h-7">
805
+ <Layers className="h-3 w-3 mr-1" />
806
+ Material
807
+ </Button>
808
+ </DialogTrigger>
809
+ <DialogContent className="sm:max-w-md">
810
+ <DialogHeader>
811
+ <DialogTitle className="flex items-center gap-2">
812
+ <Layers className="h-4 w-4" />
813
+ Add Material
814
+ </DialogTitle>
815
+ <DialogDescription>
816
+ Assign a material to this <span className="font-mono font-medium text-zinc-700 dark:text-zinc-300">{entityType}</span>.
817
+ </DialogDescription>
818
+ </DialogHeader>
819
+ <div className="grid gap-4 py-4">
820
+ {/* Material Name */}
821
+ <div className="space-y-2">
822
+ <Label className="text-sm font-medium">Material Name</Label>
823
+ <Input
824
+ value={materialName}
825
+ onChange={(e) => setMaterialName(e.target.value)}
826
+ placeholder="e.g., Concrete C30/37"
827
+ className="font-mono"
828
+ />
829
+ </div>
830
+
831
+ {/* Category */}
832
+ <div className="space-y-2">
833
+ <Label className="text-sm font-medium">Category</Label>
834
+ <Select value={category} onValueChange={setCategory}>
835
+ <SelectTrigger>
836
+ <SelectValue placeholder="Select category..." />
837
+ </SelectTrigger>
838
+ <SelectContent>
839
+ {materialCategories.map((cat) => (
840
+ <SelectItem key={cat} value={cat}>{cat}</SelectItem>
841
+ ))}
842
+ </SelectContent>
843
+ </Select>
844
+ </div>
845
+
846
+ {/* Description */}
847
+ <div className="space-y-2">
848
+ <Label className="text-sm font-medium">Description (optional)</Label>
849
+ <Input
850
+ value={description}
851
+ onChange={(e) => setDescription(e.target.value)}
852
+ placeholder="Additional details about the material"
853
+ />
854
+ </div>
855
+ </div>
856
+ <DialogFooter>
857
+ <Button variant="outline" onClick={() => setOpen(false)}>
858
+ Cancel
859
+ </Button>
860
+ <Button onClick={handleSubmit} disabled={!materialName}>
861
+ Add Material
862
+ </Button>
863
+ </DialogFooter>
864
+ </DialogContent>
865
+ </Dialog>
866
+ );
867
+ }
868
+
869
+ // Common material categories - static, hoisted to module scope
870
+ const MATERIAL_CATEGORIES = [
871
+ 'Concrete', 'Steel', 'Wood', 'Masonry', 'Glass', 'Aluminium',
872
+ 'Insulation', 'Gypsum', 'Stone', 'Ceramic', 'Plastic', 'Composite',
873
+ ] as const;
874
+
875
+ // ============================================================================
876
+ // Quantity Dialog
877
+ // ============================================================================
878
+
879
+ interface AddQuantityDialogProps {
880
+ modelId: string;
881
+ entityId: number;
882
+ entityType: string;
883
+ existingQtos: string[];
884
+ }
885
+
886
+ /**
887
+ * Schema-aware dialog for adding quantities.
888
+ * Filters available quantity sets based on IFC entity type.
889
+ * Shows quantity suggestions with correct types from IFC4 standard.
890
+ */
891
+ export function AddQuantityDialog({ modelId, entityId, entityType, existingQtos }: AddQuantityDialogProps) {
892
+ const createPropertySet = useViewerStore((s) => s.createPropertySet);
893
+ const setProperty = useViewerStore((s) => s.setProperty);
894
+ const bumpMutationVersion = useViewerStore((s) => s.bumpMutationVersion);
895
+
896
+ const [open, setOpen] = useState(false);
897
+ const [qtoName, setQtoName] = useState('');
898
+ const [isCustomQto, setIsCustomQto] = useState(false);
899
+ const [customQtoName, setCustomQtoName] = useState('');
900
+ const [quantityName, setQuantityName] = useState('');
901
+ const [customQuantityName, setCustomQuantityName] = useState('');
902
+ const [value, setValue] = useState('');
903
+ const [quantityType, setQuantityType] = useState<QuantityType>(QuantityType.Length);
904
+
905
+ // Get schema-valid quantity sets for this entity type
906
+ const validQtoDefs = useMemo(() => {
907
+ return getQtoDefinitionsForType(entityType);
908
+ }, [entityType]);
909
+
910
+ // Split into: already on entity vs available to add
911
+ const { existingStandardQtos, availableStandardQtos } = useMemo(() => {
912
+ const existing: QtoDefinition[] = [];
913
+ const available: QtoDefinition[] = [];
914
+ for (const def of validQtoDefs) {
915
+ if (existingQtos.includes(def.name)) {
916
+ existing.push(def);
917
+ } else {
918
+ available.push(def);
919
+ }
920
+ }
921
+ return { existingStandardQtos: existing, availableStandardQtos: available };
922
+ }, [validQtoDefs, existingQtos]);
923
+
924
+ // Get quantity suggestions for selected qto set
925
+ const quantitySuggestions = useMemo((): QtoQuantityDef[] => {
926
+ if (!qtoName || isCustomQto) return [];
927
+ return getQuantitiesForQto(qtoName);
928
+ }, [qtoName, isCustomQto]);
929
+
930
+ const effectiveQtoName = isCustomQto ? customQtoName : qtoName;
931
+ const effectiveQuantityName = quantityName || customQuantityName;
932
+
933
+ // Auto-update type when selecting a standard quantity
934
+ const handleQuantitySelect = useCallback((name: string) => {
935
+ setQuantityName(name);
936
+ setCustomQuantityName('');
937
+ const qtyDef = quantitySuggestions.find(q => q.name === name);
938
+ if (qtyDef) {
939
+ setQuantityType(qtyDef.type);
940
+ }
941
+ }, [quantitySuggestions]);
942
+
943
+ const handleSubmit = useCallback(() => {
944
+ if (!effectiveQtoName || !effectiveQuantityName) return;
945
+
946
+ let normalizedModelId = modelId;
947
+ if (modelId === 'legacy') {
948
+ normalizedModelId = '__legacy__';
949
+ }
950
+
951
+ const parsedValue = parseFloat(value) || 0;
952
+
953
+ // Store quantity as a property set (mutation system uses property sets)
954
+ const qtoExists = existingQtos.includes(effectiveQtoName);
955
+
956
+ if (!qtoExists) {
957
+ createPropertySet(normalizedModelId, entityId, effectiveQtoName, [
958
+ { name: effectiveQuantityName, value: parsedValue, type: PropertyValueType.Real },
959
+ ]);
960
+ } else {
961
+ setProperty(normalizedModelId, entityId, effectiveQtoName, effectiveQuantityName, parsedValue, PropertyValueType.Real);
962
+ }
963
+
964
+ bumpMutationVersion();
965
+
966
+ // Reset form
967
+ setQtoName('');
968
+ setCustomQtoName('');
969
+ setQuantityName('');
970
+ setCustomQuantityName('');
971
+ setValue('');
972
+ setQuantityType(QuantityType.Length);
973
+ setIsCustomQto(false);
974
+ setOpen(false);
975
+ }, [modelId, entityId, effectiveQtoName, effectiveQuantityName, value, existingQtos, setProperty, createPropertySet, bumpMutationVersion]);
976
+
977
+ const resetForm = useCallback(() => {
978
+ setQtoName('');
979
+ setCustomQtoName('');
980
+ setQuantityName('');
981
+ setCustomQuantityName('');
982
+ setValue('');
983
+ setQuantityType(QuantityType.Length);
984
+ setIsCustomQto(false);
985
+ }, []);
986
+
987
+ return (
988
+ <Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) resetForm(); }}>
989
+ <DialogTrigger asChild>
990
+ <Button variant="outline" size="sm" className="h-7">
991
+ <Ruler className="h-3 w-3 mr-1" />
992
+ Quantity
993
+ </Button>
994
+ </DialogTrigger>
995
+ <DialogContent className="sm:max-w-lg">
996
+ <DialogHeader>
997
+ <DialogTitle className="flex items-center gap-2">
998
+ <Ruler className="h-4 w-4" />
999
+ Add Quantity
1000
+ </DialogTitle>
1001
+ <DialogDescription>
1002
+ Add a quantity to this <span className="font-mono font-medium text-zinc-700 dark:text-zinc-300">{entityType}</span> element.
1003
+ {validQtoDefs.length > 0 && (
1004
+ <span className="block mt-1 text-emerald-600 dark:text-emerald-400">
1005
+ IFC4 schema: {validQtoDefs.length} standard quantity set{validQtoDefs.length !== 1 ? 's' : ''} available
1006
+ </span>
1007
+ )}
1008
+ </DialogDescription>
1009
+ </DialogHeader>
1010
+ <div className="grid gap-4 py-4">
1011
+ {/* Quantity Set Selection */}
1012
+ <div className="space-y-2">
1013
+ <div className="flex items-center justify-between">
1014
+ <Label className="text-sm font-medium">Quantity Set</Label>
1015
+ <Button
1016
+ variant="ghost"
1017
+ size="sm"
1018
+ className="h-6 px-2 text-[10px]"
1019
+ onClick={() => { setIsCustomQto(!isCustomQto); setQtoName(''); setCustomQtoName(''); setQuantityName(''); setCustomQuantityName(''); }}
1020
+ >
1021
+ {isCustomQto ? 'Use standard' : 'Custom name'}
1022
+ </Button>
1023
+ </div>
1024
+ {isCustomQto ? (
1025
+ <Input
1026
+ value={customQtoName}
1027
+ onChange={(e) => setCustomQtoName(e.target.value)}
1028
+ placeholder="e.g., Qto_MyCustomQuantities"
1029
+ className="font-mono text-sm"
1030
+ />
1031
+ ) : (
1032
+ <Select value={qtoName} onValueChange={(v) => { setQtoName(v); setQuantityName(''); setCustomQuantityName(''); setValue(''); }}>
1033
+ <SelectTrigger className="font-mono text-sm">
1034
+ <SelectValue placeholder="Select quantity set..." />
1035
+ </SelectTrigger>
1036
+ <SelectContent>
1037
+ {existingStandardQtos.length > 0 && (
1038
+ <>
1039
+ <div className="px-2 py-1.5 text-[10px] font-bold uppercase tracking-wider text-zinc-400">
1040
+ On this element
1041
+ </div>
1042
+ {existingStandardQtos.map((def) => (
1043
+ <SelectItem key={def.name} value={def.name}>
1044
+ <div className="flex items-center gap-2">
1045
+ <span>{def.name}</span>
1046
+ <Badge variant="secondary" className="h-4 px-1 text-[9px]">existing</Badge>
1047
+ </div>
1048
+ </SelectItem>
1049
+ ))}
1050
+ </>
1051
+ )}
1052
+ {existingQtos.filter(q => !existingStandardQtos.some(d => d.name === q)).length > 0 && (
1053
+ <>
1054
+ <div className="px-2 py-1.5 text-[10px] font-bold uppercase tracking-wider text-zinc-400">
1055
+ Existing (custom)
1056
+ </div>
1057
+ {existingQtos.filter(q => !existingStandardQtos.some(d => d.name === q)).map((name) => (
1058
+ <SelectItem key={name} value={name}>
1059
+ <span>{name}</span>
1060
+ </SelectItem>
1061
+ ))}
1062
+ </>
1063
+ )}
1064
+ {availableStandardQtos.length > 0 && (
1065
+ <>
1066
+ <div className="px-2 py-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
1067
+ IFC4 Standard — {entityType}
1068
+ </div>
1069
+ {availableStandardQtos.map((def) => (
1070
+ <SelectItem key={def.name} value={def.name}>
1071
+ <div className="flex flex-col">
1072
+ <div className="flex items-center gap-2">
1073
+ <span className="font-medium">{def.name}</span>
1074
+ <Badge variant="outline" className="h-4 px-1 text-[9px] border-emerald-300 text-emerald-600">new</Badge>
1075
+ </div>
1076
+ <span className="text-[10px] text-zinc-400">{def.description}</span>
1077
+ </div>
1078
+ </SelectItem>
1079
+ ))}
1080
+ </>
1081
+ )}
1082
+ </SelectContent>
1083
+ </Select>
1084
+ )}
1085
+ </div>
1086
+
1087
+ {/* Quantity Selection */}
1088
+ <div className="space-y-2">
1089
+ <Label className="text-sm font-medium">Quantity</Label>
1090
+ {quantitySuggestions.length > 0 ? (
1091
+ <div className="space-y-2">
1092
+ <Select value={quantityName} onValueChange={handleQuantitySelect}>
1093
+ <SelectTrigger className="font-mono text-sm">
1094
+ <SelectValue placeholder="Select quantity..." />
1095
+ </SelectTrigger>
1096
+ <SelectContent>
1097
+ {quantitySuggestions.map((qty) => (
1098
+ <SelectItem key={qty.name} value={qty.name}>
1099
+ <div className="flex flex-col">
1100
+ <div className="flex items-center gap-2">
1101
+ <span className="font-medium">{qty.name}</span>
1102
+ <Badge variant="secondary" className="h-4 px-1 text-[9px]">{qty.unit}</Badge>
1103
+ </div>
1104
+ <span className="text-[10px] text-zinc-400">{qty.description}</span>
1105
+ </div>
1106
+ </SelectItem>
1107
+ ))}
1108
+ </SelectContent>
1109
+ </Select>
1110
+ {!quantityName && (
1111
+ <Input
1112
+ value={customQuantityName}
1113
+ onChange={(e) => setCustomQuantityName(e.target.value)}
1114
+ placeholder="Or type custom quantity name..."
1115
+ className="font-mono text-sm"
1116
+ />
1117
+ )}
1118
+ </div>
1119
+ ) : (
1120
+ <Input
1121
+ value={customQuantityName}
1122
+ onChange={(e) => setCustomQuantityName(e.target.value)}
1123
+ placeholder="e.g., Length"
1124
+ className="font-mono text-sm"
1125
+ />
1126
+ )}
1127
+ </div>
1128
+
1129
+ {/* Value input */}
1130
+ <div className="space-y-2">
1131
+ <Label className="text-sm font-medium">
1132
+ Value
1133
+ {quantityName && (
1134
+ <span className="ml-2 text-xs text-zinc-400 font-normal">
1135
+ ({getQuantityUnit(quantityType)})
1136
+ </span>
1137
+ )}
1138
+ </Label>
1139
+ <Input
1140
+ value={value}
1141
+ onChange={(e) => setValue(e.target.value)}
1142
+ placeholder="Numeric value"
1143
+ type="number"
1144
+ step="any"
1145
+ className="font-mono text-sm"
1146
+ />
1147
+ </div>
1148
+ </div>
1149
+ <DialogFooter>
1150
+ <Button variant="outline" onClick={() => { setOpen(false); resetForm(); }}>
1151
+ Cancel
1152
+ </Button>
1153
+ <Button onClick={handleSubmit} disabled={!effectiveQtoName || !effectiveQuantityName}>
1154
+ Add Quantity
1155
+ </Button>
1156
+ </DialogFooter>
1157
+ </DialogContent>
1158
+ </Dialog>
1159
+ );
1160
+ }
1161
+
1162
+ // ============================================================================
1163
+ // Edit Toolbar (combines all add actions)
1164
+ // ============================================================================
1165
+
1166
+ interface EditToolbarProps {
1167
+ modelId: string;
1168
+ entityId: number;
1169
+ entityType: string;
1170
+ existingPsets: string[];
1171
+ existingQtos?: string[];
1172
+ schemaVersion?: string;
1173
+ }
1174
+
1175
+ /**
1176
+ * Edit mode toolbar with dropdown for adding properties, classifications, materials, and quantities.
1177
+ * Schema-aware: filters available property/quantity sets based on entity type.
1178
+ */
1179
+ export function EditToolbar({ modelId, entityId, entityType, existingPsets, existingQtos, schemaVersion }: EditToolbarProps) {
1180
+ return (
1181
+ <div className="flex items-center justify-between gap-2 mb-3 pb-2 border-b border-purple-200 dark:border-purple-800 bg-purple-50/30 dark:bg-purple-950/20 -mx-3 -mt-3 px-3 pt-3">
1182
+ <div className="flex items-center gap-1.5 flex-wrap">
1183
+ <NewPropertyDialog
1184
+ modelId={modelId}
1185
+ entityId={entityId}
1186
+ entityType={entityType}
1187
+ existingPsets={existingPsets}
1188
+ schemaVersion={schemaVersion}
1189
+ />
1190
+ <AddQuantityDialog
1191
+ modelId={modelId}
1192
+ entityId={entityId}
1193
+ entityType={entityType}
1194
+ existingQtos={existingQtos ?? []}
1195
+ />
1196
+ <AddClassificationDialog
1197
+ modelId={modelId}
1198
+ entityId={entityId}
1199
+ entityType={entityType}
1200
+ />
1201
+ <AddMaterialDialog
1202
+ modelId={modelId}
1203
+ entityId={entityId}
1204
+ entityType={entityType}
1205
+ />
1206
+ </div>
1207
+ <UndoRedoButtons modelId={modelId} />
1208
+ </div>
1209
+ );
1210
+ }
1211
+
1212
+ // ============================================================================
1213
+ // Undo/Redo
1214
+ // ============================================================================
1215
+
430
1216
  interface UndoRedoButtonsProps {
431
1217
  modelId: string;
432
1218
  }
@@ -488,7 +1274,9 @@ export function UndoRedoButtons({ modelId }: UndoRedoButtonsProps) {
488
1274
  );
489
1275
  }
490
1276
 
491
- // Helper functions
1277
+ // ============================================================================
1278
+ // Helper Functions
1279
+ // ============================================================================
492
1280
 
493
1281
  /**
494
1282
  * Extract the raw value from typed IFC values.
@@ -524,7 +1312,7 @@ function formatValue(value: unknown): string {
524
1312
 
525
1313
  function formatDisplayValue(value: unknown): string {
526
1314
  const raw = extractRawValue(value);
527
- if (raw === null || raw === undefined) return '';
1315
+ if (raw === null || raw === undefined) return '\u2014';
528
1316
  if (typeof raw === 'boolean') return raw ? 'True' : 'False';
529
1317
  if (typeof raw === 'number') {
530
1318
  return Number.isInteger(raw)
@@ -533,11 +1321,12 @@ function formatDisplayValue(value: unknown): string {
533
1321
  }
534
1322
  if (Array.isArray(raw)) return JSON.stringify(raw);
535
1323
 
536
- // Handle boolean strings
1324
+ // Handle boolean strings (STEP enum format)
537
1325
  const strVal = String(raw);
538
- if (strVal === '.T.' || strVal.toUpperCase() === '.T.') return 'True';
539
- if (strVal === '.F.' || strVal.toUpperCase() === '.F.') return 'False';
540
- if (strVal === '.U.' || strVal.toUpperCase() === '.U.') return 'Unknown';
1326
+ const upper = strVal.toUpperCase();
1327
+ if (upper === '.T.') return 'True';
1328
+ if (upper === '.F.') return 'False';
1329
+ if (upper === '.U.') return 'Unknown';
541
1330
  return strVal;
542
1331
  }
543
1332