@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
@@ -17,261 +17,31 @@ import {
17
17
  MousePointer2,
18
18
  ArrowUpDown,
19
19
  FileBox,
20
- Clock,
21
- HardDrive,
22
- Hash,
23
- Database,
24
- Edit3,
25
- Sparkles,
26
20
  PenLine,
27
21
  Crosshair,
28
22
  } from 'lucide-react';
29
- import { PropertyEditor, NewPropertyDialog, UndoRedoButtons } from './PropertyEditor';
23
+ import { EditToolbar } from './PropertyEditor';
30
24
  import { Button } from '@/components/ui/button';
31
25
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
32
26
  import { ScrollArea } from '@/components/ui/scroll-area';
33
27
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
34
28
  import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
35
- import { Badge } from '@/components/ui/badge';
36
29
  import { useViewerStore } from '@/store';
37
30
  import { useIfc } from '@/hooks/useIfc';
38
31
  import { IfcQuery } from '@ifc-lite/query';
39
32
  import { MutablePropertyView } from '@ifc-lite/mutations';
40
- import { extractPropertiesOnDemand, type IfcDataStore } from '@ifc-lite/parser';
33
+ import { extractPropertiesOnDemand, extractClassificationsOnDemand, extractMaterialsOnDemand, extractTypePropertiesOnDemand, extractDocumentsOnDemand, extractRelationshipsOnDemand, type IfcDataStore } from '@ifc-lite/parser';
41
34
  import type { EntityRef, FederatedModel } from '@/store/types';
42
35
 
43
- interface PropertySet {
44
- name: string;
45
- properties: Array<{ name: string; value: unknown; isMutated?: boolean }>;
46
- isNewPset?: boolean;
47
- }
48
-
49
- interface QuantitySet {
50
- name: string;
51
- quantities: Array<{ name: string; value: number; type: number }>;
52
- }
53
-
54
- /**
55
- * Result of parsing a property value.
56
- * Contains the display value and optional IFC type for tooltip.
57
- */
58
- interface ParsedPropertyValue {
59
- displayValue: string;
60
- ifcType?: string;
61
- }
62
-
63
- /**
64
- * Map of IFC boolean enumeration values to human-readable text
65
- */
66
- const BOOLEAN_MAP: Record<string, string> = {
67
- '.T.': 'True',
68
- '.F.': 'False',
69
- '.U.': 'Unknown',
70
- };
71
-
72
- /**
73
- * Friendly names for common IFC types (shown in tooltips)
74
- */
75
- const IFC_TYPE_DISPLAY_NAMES: Record<string, string> = {
76
- 'IFCBOOLEAN': 'Boolean',
77
- 'IFCLOGICAL': 'Logical',
78
- 'IFCIDENTIFIER': 'Identifier',
79
- 'IFCLABEL': 'Label',
80
- 'IFCTEXT': 'Text',
81
- 'IFCREAL': 'Real',
82
- 'IFCINTEGER': 'Integer',
83
- 'IFCPOSITIVELENGTHMEASURE': 'Length',
84
- 'IFCLENGTHMEASURE': 'Length',
85
- 'IFCAREAMEASURE': 'Area',
86
- 'IFCVOLUMEMEASURE': 'Volume',
87
- 'IFCMASSMEASURE': 'Mass',
88
- 'IFCTHERMALTRANSMITTANCEMEASURE': 'Thermal Transmittance',
89
- 'IFCPRESSUREMEASURE': 'Pressure',
90
- 'IFCFORCEMEASURE': 'Force',
91
- 'IFCPLANEANGLEMEASURE': 'Angle',
92
- 'IFCTIMEMEASURE': 'Time',
93
- 'IFCNORMALISEDRATIOMEASURE': 'Ratio',
94
- 'IFCRATIOMEASURE': 'Ratio',
95
- 'IFCPOSITIVERATIOMEASURE': 'Ratio',
96
- 'IFCCOUNTMEASURE': 'Count',
97
- 'IFCMONETARYMEASURE': 'Currency',
98
- };
99
-
100
- /**
101
- * Decode IFC STEP encoded strings.
102
- * Handles:
103
- * - \X2\XXXX\X0\ - Unicode hex encoding (e.g., \X2\00E4\X0\ → ä)
104
- * - \X\XX\ - ISO-8859-1 hex encoding
105
- * - \S\X - Extended ASCII with escape
106
- */
107
- function decodeIfcString(str: string): string {
108
- if (!str || typeof str !== 'string') return str;
109
-
110
- let result = str;
111
-
112
- // Decode \X2\XXXX\X0\ patterns (Unicode 2-byte hex, can have multiple chars)
113
- // Pattern: \X2\ followed by hex pairs, ended by \X0\
114
- result = result.replace(/\\X2\\([0-9A-Fa-f]+)\\X0\\/g, (_, hex) => {
115
- // hex can be multiple 4-char sequences (e.g., "00E400FC" for "äü")
116
- let decoded = '';
117
- for (let i = 0; i < hex.length; i += 4) {
118
- const charCode = parseInt(hex.substring(i, i + 4), 16);
119
- if (!isNaN(charCode)) {
120
- decoded += String.fromCharCode(charCode);
121
- }
122
- }
123
- return decoded;
124
- });
125
-
126
- // Decode \X4\XXXXXXXX\X0\ patterns (Unicode 4-byte hex for chars outside BMP)
127
- result = result.replace(/\\X4\\([0-9A-Fa-f]+)\\X0\\/g, (_, hex) => {
128
- let decoded = '';
129
- for (let i = 0; i < hex.length; i += 8) {
130
- const codePoint = parseInt(hex.substring(i, i + 8), 16);
131
- if (!isNaN(codePoint)) {
132
- decoded += String.fromCodePoint(codePoint);
133
- }
134
- }
135
- return decoded;
136
- });
137
-
138
- // Decode \X\XX\ patterns (ISO-8859-1 single byte)
139
- result = result.replace(/\\X\\([0-9A-Fa-f]{2})/g, (_, hex) => {
140
- const charCode = parseInt(hex, 16);
141
- return !isNaN(charCode) ? String.fromCharCode(charCode) : '';
142
- });
143
-
144
- // Decode \S\X patterns (Latin extended, offset by 128)
145
- result = result.replace(/\\S\\(.)/g, (_, char) => {
146
- return String.fromCharCode(char.charCodeAt(0) + 128);
147
- });
148
-
149
- // Decode \P..\ code page switches (simplified - just remove them)
150
- result = result.replace(/\\P[A-Z]?\\/g, '');
151
-
152
- return result;
153
- }
154
-
155
- /**
156
- * Parse and format a property value for display.
157
- * Handles:
158
- * - TypedValues like [IFCIDENTIFIER, '100 x 150mm'] -> display '100 x 150mm', tooltip 'Identifier'
159
- * - Boolean enums like '.T.' -> 'True'
160
- * - IFC encoded strings with \X2\, \X\ escape sequences
161
- * - Null/undefined -> '—'
162
- * - Regular values -> string conversion
163
- */
164
- function parsePropertyValue(value: unknown): ParsedPropertyValue {
165
- // Handle null/undefined
166
- if (value === null || value === undefined) {
167
- return { displayValue: '—' };
168
- }
169
-
170
- // Handle typed value arrays [IFCTYPENAME, actualValue]
171
- if (Array.isArray(value) && value.length === 2 && typeof value[0] === 'string') {
172
- const [ifcType, innerValue] = value;
173
- const typeName = ifcType.toUpperCase();
174
- const friendlyType = IFC_TYPE_DISPLAY_NAMES[typeName] || typeName.replace(/^IFC/, '');
175
-
176
- // Recursively parse the inner value
177
- const parsed = parsePropertyValue(innerValue);
178
- return {
179
- displayValue: parsed.displayValue,
180
- ifcType: friendlyType,
181
- };
182
- }
183
-
184
- // Handle boolean enumeration values
185
- if (typeof value === 'string') {
186
- const upperVal = value.toUpperCase();
187
- if (BOOLEAN_MAP[upperVal]) {
188
- return { displayValue: BOOLEAN_MAP[upperVal], ifcType: 'Boolean' };
189
- }
190
-
191
- // Handle string that contains typed value pattern (from String(array) conversion)
192
- // Pattern: "IFCTYPENAME,actualValue" or just "IFCTYPENAME," (empty value)
193
- const typedMatch = value.match(/^(IFC[A-Z0-9_]+),(.*)$/i);
194
- if (typedMatch) {
195
- const [, ifcType, innerValue] = typedMatch;
196
- const typeName = ifcType.toUpperCase();
197
- const friendlyType = IFC_TYPE_DISPLAY_NAMES[typeName] || typeName.replace(/^IFC/, '');
198
-
199
- // Handle empty value after type
200
- if (!innerValue || innerValue.trim() === '') {
201
- return { displayValue: '—', ifcType: friendlyType };
202
- }
203
-
204
- // Check if the inner value is a boolean
205
- const upperInner = innerValue.toUpperCase().trim();
206
- if (BOOLEAN_MAP[upperInner]) {
207
- return { displayValue: BOOLEAN_MAP[upperInner], ifcType: friendlyType };
208
- }
209
-
210
- // Decode IFC string encoding and return
211
- return { displayValue: decodeIfcString(innerValue), ifcType: friendlyType };
212
- }
213
-
214
- // Regular string - decode IFC encoding
215
- return { displayValue: decodeIfcString(value) };
216
- }
217
-
218
- // Handle native booleans
219
- if (typeof value === 'boolean') {
220
- return { displayValue: value ? 'True' : 'False', ifcType: 'Boolean' };
221
- }
222
-
223
- // Handle numbers
224
- if (typeof value === 'number') {
225
- // Format numbers nicely (limit decimal places, use locale formatting)
226
- const formatted = Number.isInteger(value)
227
- ? value.toLocaleString()
228
- : value.toLocaleString(undefined, { maximumFractionDigits: 6 });
229
- return { displayValue: formatted };
230
- }
231
-
232
- // Fallback for other types
233
- return { displayValue: String(value) };
234
- }
235
-
236
- /** Inline coordinate value with dim axis label */
237
- function CoordVal({ axis, value }: { axis: string; value: number }) {
238
- return (
239
- <span className="whitespace-nowrap"><span className="opacity-50">{axis}</span>{'\u2009'}{value.toFixed(3)}</span>
240
- );
241
- }
242
-
243
- /** Copyable coordinate row: label + values with copy button hugging the values */
244
- function CoordRow({ label, values, primary, copyLabel, coordCopied, onCopy }: {
245
- label: string;
246
- values: { axis: string; value: number }[];
247
- primary?: boolean;
248
- copyLabel: string;
249
- coordCopied: string | null;
250
- onCopy: (label: string, text: string) => void;
251
- }) {
252
- const isCopied = coordCopied === copyLabel;
253
- const copyText = values.map(v => v.value.toFixed(3)).join(', ');
254
- return (
255
- <div className="flex items-start gap-1.5 group min-w-0">
256
- {label && (
257
- <span className={`text-[9px] font-medium uppercase tracking-wider w-[34px] shrink-0 pt-px ${primary ? 'text-muted-foreground' : 'text-muted-foreground/50'}`}>
258
- {label}
259
- </span>
260
- )}
261
- <span className={`font-mono text-[10px] min-w-0 tabular-nums leading-relaxed ${primary ? 'text-foreground' : 'text-muted-foreground/60'}`}>
262
- {values.map((v, i) => (
263
- <span key={v.axis}>{i > 0 && <>{' '}</>}<CoordVal axis={v.axis} value={v.value} /></span>
264
- ))}
265
- </span>
266
- <button
267
- className={`shrink-0 p-0.5 rounded mt-px transition-colors ${isCopied ? 'text-emerald-500' : 'text-muted-foreground/30 opacity-0 group-hover:opacity-100 hover:text-muted-foreground'}`}
268
- onClick={(e) => { e.stopPropagation(); onCopy(copyLabel, copyText); }}
269
- >
270
- {isCopied ? <Check className="h-2.5 w-2.5" /> : <Copy className="h-2.5 w-2.5" />}
271
- </button>
272
- </div>
273
- );
274
- }
36
+ import { CoordVal, CoordRow } from './properties/CoordinateDisplay';
37
+ import { PropertySetCard } from './properties/PropertySetCard';
38
+ import { QuantitySetCard } from './properties/QuantitySetCard';
39
+ import { ModelMetadataPanel } from './properties/ModelMetadataPanel';
40
+ import { ClassificationCard } from './properties/ClassificationCard';
41
+ import { MaterialCard } from './properties/MaterialCard';
42
+ import { DocumentCard } from './properties/DocumentCard';
43
+ import { RelationshipsCard } from './properties/RelationshipsCard';
44
+ import type { PropertySet, QuantitySet } from './properties/encodingUtils';
275
45
 
276
46
  export function PropertiesPanel() {
277
47
  const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
@@ -424,7 +194,7 @@ export function PropertiesPanel() {
424
194
  //
425
195
  // The full coordinate pipeline is:
426
196
  // 1. WASM extracts IFC positions (Z-up) and applies RTC offset (wasmRtcOffset, in Z-up)
427
- // 2. Mesh collector converts Z-up Y-up: newY = oldZ, newZ = -oldY
197
+ // 2. Mesh collector converts Z-up -> Y-up: newY = oldZ, newZ = -oldY
428
198
  // 3. CoordinateHandler may apply additional originShift (in Y-up) for large coordinates
429
199
  // 4. Multi-model alignment adjusts positions so all models share the first model's RTC frame
430
200
  //
@@ -539,26 +309,15 @@ export function PropertiesPanel() {
539
309
  modelId = '__legacy__';
540
310
  }
541
311
 
542
- // DEBUG: Log what we're working with
543
- console.log('[PropertiesPanel] modelId:', modelId, 'expressId:', expressId, 'mutationVersion:', mutationVersion);
544
- console.log('[PropertiesPanel] mutationViews keys:', [...mutationViews.keys()]);
545
-
546
312
  // Try to get properties from mutation view first (handles both base and mutations)
547
313
  const mutationView = modelId ? mutationViews.get(modelId) : null;
548
- console.log('[PropertiesPanel] mutationView exists:', !!mutationView);
549
314
 
550
315
  if (mutationView && expressId) {
551
- // DEBUG: Log mutation view state
552
- const allMutations = mutationView.getMutations();
553
- console.log('[PropertiesPanel] All mutations in view:', allMutations.length, allMutations);
554
-
555
316
  // Get merged properties from mutation view (base + mutations applied)
556
317
  const mergedProps = mutationView.getForEntity(expressId);
557
- console.log('[PropertiesPanel] mergedProps from getForEntity:', mergedProps.length, mergedProps);
558
318
 
559
319
  // Get list of actual mutations to track which properties changed
560
320
  const mutations = mutationView.getMutationsForEntity(expressId);
561
- console.log('[PropertiesPanel] mutations for this entity:', mutations.length, mutations);
562
321
 
563
322
  // Build a set of mutated property keys for quick lookup
564
323
  const mutatedKeys = new Set<string>();
@@ -613,16 +372,109 @@ export function PropertiesPanel() {
613
372
  }, [entityNode]);
614
373
 
615
374
  // Build attributes array for display - must be before early return to maintain hook order
375
+ // Uses schema-aware extraction to show ALL string/enum attributes for the entity type.
616
376
  // Note: GlobalId is intentionally excluded since it's shown in the dedicated GUID field above
617
377
  const attributes = useMemo(() => {
618
378
  if (!entityNode) return [];
619
- const attrs: Array<{ name: string; value: string }> = [];
620
- if (entityNode.name) attrs.push({ name: 'Name', value: entityNode.name });
621
- if (entityNode.description) attrs.push({ name: 'Description', value: entityNode.description });
622
- if (entityNode.objectType) attrs.push({ name: 'ObjectType', value: entityNode.objectType });
623
- return attrs;
379
+ return entityNode.allAttributes();
624
380
  }, [entityNode]);
625
381
 
382
+ // Extract classifications for the selected entity from the IFC data store
383
+ const classifications = useMemo(() => {
384
+ if (!selectedEntity) return [];
385
+ const dataStore = model?.ifcDataStore ?? ifcDataStore;
386
+ if (!dataStore) return [];
387
+ return extractClassificationsOnDemand(dataStore as IfcDataStore, selectedEntity.expressId);
388
+ }, [selectedEntity, model, ifcDataStore]);
389
+
390
+ // Extract materials for the selected entity from the IFC data store
391
+ const materialInfo = useMemo(() => {
392
+ if (!selectedEntity) return null;
393
+ const dataStore = model?.ifcDataStore ?? ifcDataStore;
394
+ if (!dataStore) return null;
395
+ return extractMaterialsOnDemand(dataStore as IfcDataStore, selectedEntity.expressId);
396
+ }, [selectedEntity, model, ifcDataStore]);
397
+
398
+ // Extract documents for the selected entity from the IFC data store
399
+ const documents = useMemo(() => {
400
+ if (!selectedEntity) return [];
401
+ const dataStore = model?.ifcDataStore ?? ifcDataStore;
402
+ if (!dataStore) return [];
403
+ return extractDocumentsOnDemand(dataStore as IfcDataStore, selectedEntity.expressId);
404
+ }, [selectedEntity, model, ifcDataStore]);
405
+
406
+ // Extract structural relationships (openings, fills, groups, connections)
407
+ const entityRelationships = useMemo(() => {
408
+ if (!selectedEntity) return null;
409
+ const dataStore = model?.ifcDataStore ?? ifcDataStore;
410
+ if (!dataStore) return null;
411
+ const rels = extractRelationshipsOnDemand(dataStore as IfcDataStore, selectedEntity.expressId);
412
+ const totalCount = rels.voids.length + rels.fills.length + rels.groups.length + rels.connections.length;
413
+ return totalCount > 0 ? rels : null;
414
+ }, [selectedEntity, model, ifcDataStore]);
415
+
416
+ // Extract type-level properties (e.g., from IfcWallType's HasPropertySets)
417
+ const typeProperties = useMemo(() => {
418
+ if (!selectedEntity) return null;
419
+ const dataStore = model?.ifcDataStore ?? ifcDataStore;
420
+ if (!dataStore) return null;
421
+ const result = extractTypePropertiesOnDemand(dataStore as IfcDataStore, selectedEntity.expressId);
422
+ if (!result) return null;
423
+ // Convert to PropertySet format for PropertySetCard
424
+ return {
425
+ typeName: result.typeName,
426
+ typeId: result.typeId,
427
+ psets: result.properties.map(pset => ({
428
+ name: pset.name,
429
+ properties: pset.properties.map(p => ({ name: p.name, value: p.value, isMutated: false })),
430
+ isNewPset: false,
431
+ })),
432
+ };
433
+ }, [selectedEntity, model, ifcDataStore]);
434
+
435
+ // Merge instance-level and type-level property sets into a single unified list.
436
+ // - Same-named psets: instance properties take precedence, type-level props are appended (deduped by name)
437
+ // - Type-only psets: added to the main list (no separate "Type" section)
438
+ // This matches how reference IFC viewers display properties.
439
+ const mergedProperties: PropertySet[] = useMemo(() => {
440
+ if (!typeProperties || typeProperties.psets.length === 0) return properties;
441
+ if (properties.length === 0) return typeProperties.psets;
442
+
443
+ const instanceByName = new Map<string, PropertySet>();
444
+ for (const pset of properties) {
445
+ instanceByName.set(pset.name, pset);
446
+ }
447
+
448
+ // Start with instance psets, merging type-level props into matching ones
449
+ const result: PropertySet[] = [];
450
+ const merged = new Set<string>();
451
+
452
+ for (const pset of properties) {
453
+ const typePset = typeProperties.psets.find(tp => tp.name === pset.name);
454
+ if (typePset) {
455
+ // Merge: add type-level properties not already present in instance pset
456
+ const existingNames = new Set(pset.properties.map(p => p.name));
457
+ const extraProps = typePset.properties.filter(p => !existingNames.has(p.name));
458
+ result.push({
459
+ ...pset,
460
+ properties: [...pset.properties, ...extraProps],
461
+ });
462
+ merged.add(pset.name);
463
+ } else {
464
+ result.push(pset);
465
+ }
466
+ }
467
+
468
+ // Add type-only psets that don't exist at instance level
469
+ for (const typePset of typeProperties.psets) {
470
+ if (!merged.has(typePset.name) && !instanceByName.has(typePset.name)) {
471
+ result.push(typePset);
472
+ }
473
+ }
474
+
475
+ return result;
476
+ }, [properties, typeProperties]);
477
+
626
478
  // Model metadata display (when clicking top-level model in hierarchy)
627
479
  if (selectedModelId) {
628
480
  const selectedModel = models.get(selectedModelId);
@@ -915,20 +767,27 @@ export function PropertiesPanel() {
915
767
  <TabsContent value="properties" className="m-0 p-3 overflow-hidden">
916
768
  {/* Edit toolbar - only shown when edit mode is active */}
917
769
  {editMode && selectedEntity && (
918
- <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">
919
- <NewPropertyDialog
920
- modelId={selectedEntity.modelId}
921
- entityId={selectedEntity.expressId}
922
- existingPsets={properties.map(p => p.name)}
923
- />
924
- <UndoRedoButtons modelId={selectedEntity.modelId} />
925
- </div>
770
+ <EditToolbar
771
+ modelId={selectedEntity.modelId}
772
+ entityId={selectedEntity.expressId}
773
+ entityType={entityType}
774
+ existingPsets={mergedProperties.map(p => p.name)}
775
+ existingQtos={quantities.map(q => q.name)}
776
+ schemaVersion={activeDataStore?.schemaVersion}
777
+ />
926
778
  )}
927
- {properties.length === 0 ? (
779
+ {mergedProperties.length === 0 && classifications.length === 0 && !materialInfo && documents.length === 0 ? (
928
780
  <p className="text-sm text-zinc-500 dark:text-zinc-500 text-center py-8 font-mono">No property sets</p>
929
781
  ) : (
930
782
  <div className="space-y-3 w-full overflow-hidden">
931
- {properties.map((pset: PropertySet) => (
783
+ {/* Type badge - show which type this element inherits from */}
784
+ {typeProperties && typeProperties.psets.length > 0 && (
785
+ <div className="flex items-center gap-2 px-1 pb-0.5 text-[11px] text-indigo-600/70 dark:text-indigo-400/60">
786
+ <Building2 className="h-3 w-3 shrink-0" />
787
+ <span className="font-medium truncate">Type: {typeProperties.typeName}</span>
788
+ </div>
789
+ )}
790
+ {mergedProperties.map((pset: PropertySet) => (
932
791
  <PropertySetCard
933
792
  key={pset.name}
934
793
  pset={pset}
@@ -937,6 +796,48 @@ export function PropertiesPanel() {
937
796
  enableEditing={editMode}
938
797
  />
939
798
  ))}
799
+
800
+ {/* Classifications */}
801
+ {classifications.length > 0 && (
802
+ <>
803
+ {mergedProperties.length > 0 && (
804
+ <div className="border-t border-zinc-200 dark:border-zinc-800 pt-2 mt-2" />
805
+ )}
806
+ {classifications.map((classification, i) => (
807
+ <ClassificationCard key={`class-${i}`} classification={classification} />
808
+ ))}
809
+ </>
810
+ )}
811
+
812
+ {/* Materials */}
813
+ {materialInfo && (
814
+ <>
815
+ {(mergedProperties.length > 0 || classifications.length > 0) && (
816
+ <div className="border-t border-zinc-200 dark:border-zinc-800 pt-2 mt-2" />
817
+ )}
818
+ <MaterialCard material={materialInfo} />
819
+ </>
820
+ )}
821
+
822
+ {/* Documents */}
823
+ {documents.length > 0 && (
824
+ <>
825
+ {(mergedProperties.length > 0 || classifications.length > 0 || materialInfo) && (
826
+ <div className="border-t border-zinc-200 dark:border-zinc-800 pt-2 mt-2" />
827
+ )}
828
+ {documents.map((doc, i) => (
829
+ <DocumentCard key={`doc-${i}`} document={doc} />
830
+ ))}
831
+ </>
832
+ )}
833
+
834
+ {/* Relationships */}
835
+ {entityRelationships && (
836
+ <>
837
+ <div className="border-t border-zinc-200 dark:border-zinc-800 pt-2 mt-2" />
838
+ <RelationshipsCard relationships={entityRelationships} />
839
+ </>
840
+ )}
940
841
  </div>
941
842
  )}
942
843
  </TabsContent>
@@ -1048,15 +949,11 @@ function EntityDataSection({
1048
949
  return entityNode.quantities();
1049
950
  }, [entityNode]);
1050
951
 
1051
- // Get attributes
952
+ // Get attributes - uses schema-aware extraction to show ALL string/enum attributes
1052
953
  // Note: GlobalId is intentionally excluded since it's shown in the dedicated GUID field above
1053
954
  const attributes = useMemo(() => {
1054
955
  if (!entityNode) return [];
1055
- const attrs: Array<{ name: string; value: string }> = [];
1056
- if (entityNode.name) attrs.push({ name: 'Name', value: entityNode.name });
1057
- if (entityNode.description) attrs.push({ name: 'Description', value: entityNode.description });
1058
- if (entityNode.objectType) attrs.push({ name: 'ObjectType', value: entityNode.objectType });
1059
- return attrs;
956
+ return entityNode.allAttributes();
1060
957
  }, [entityNode]);
1061
958
 
1062
959
  // Get elevation info
@@ -1161,392 +1058,3 @@ function EntityDataSection({
1161
1058
  </div>
1162
1059
  );
1163
1060
  }
1164
-
1165
- interface PropertySetCardProps {
1166
- pset: PropertySet;
1167
- modelId?: string;
1168
- entityId?: number;
1169
- enableEditing?: boolean;
1170
- }
1171
-
1172
- function PropertySetCard({ pset, modelId, entityId, enableEditing }: PropertySetCardProps) {
1173
- // Check if any property in this set is mutated
1174
- const hasMutations = pset.properties.some(p => p.isMutated);
1175
- const isNewPset = pset.isNewPset;
1176
-
1177
- // Dynamic styling based on mutation state
1178
- const borderClass = isNewPset
1179
- ? 'border-2 border-amber-400/50 dark:border-amber-500/30'
1180
- : hasMutations
1181
- ? 'border-2 border-purple-300/50 dark:border-purple-500/30'
1182
- : 'border-2 border-zinc-200 dark:border-zinc-800';
1183
-
1184
- const bgClass = isNewPset
1185
- ? 'bg-amber-50/30 dark:bg-amber-950/20'
1186
- : hasMutations
1187
- ? 'bg-purple-50/20 dark:bg-purple-950/10'
1188
- : 'bg-white dark:bg-zinc-950';
1189
-
1190
- return (
1191
- <Collapsible defaultOpen className={`${borderClass} ${bgClass} group w-full max-w-full overflow-hidden`}>
1192
- <CollapsibleTrigger className="flex items-center gap-2 w-full p-2.5 hover:bg-zinc-50 dark:hover:bg-zinc-900 text-left transition-colors overflow-hidden">
1193
- {isNewPset && (
1194
- <Tooltip>
1195
- <TooltipTrigger asChild>
1196
- <Sparkles className="h-3.5 w-3.5 text-amber-500 shrink-0" />
1197
- </TooltipTrigger>
1198
- <TooltipContent>New property set (not in original model)</TooltipContent>
1199
- </Tooltip>
1200
- )}
1201
- {hasMutations && !isNewPset && (
1202
- <Tooltip>
1203
- <TooltipTrigger asChild>
1204
- <PenLine className="h-3.5 w-3.5 text-purple-500 shrink-0" />
1205
- </TooltipTrigger>
1206
- <TooltipContent>Has modified properties</TooltipContent>
1207
- </Tooltip>
1208
- )}
1209
- <span className="font-bold text-xs text-zinc-900 dark:text-zinc-100 truncate flex-1 min-w-0">{decodeIfcString(pset.name)}</span>
1210
- <span className="text-[10px] font-mono bg-zinc-100 dark:bg-zinc-900 px-1.5 py-0.5 border border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-400 shrink-0">{pset.properties.length}</span>
1211
- </CollapsibleTrigger>
1212
- <CollapsibleContent>
1213
- <div className="border-t-2 border-zinc-200 dark:border-zinc-800 divide-y divide-zinc-100 dark:divide-zinc-900">
1214
- {pset.properties.map((prop: { name: string; value: unknown; isMutated?: boolean }) => {
1215
- const parsed = parsePropertyValue(prop.value);
1216
- const decodedName = decodeIfcString(prop.name);
1217
- const isMutated = prop.isMutated;
1218
-
1219
- return (
1220
- <div
1221
- key={prop.name}
1222
- className={`flex items-start justify-between gap-2 px-3 py-2 text-xs group/prop ${
1223
- isMutated
1224
- ? 'bg-purple-50/50 dark:bg-purple-950/30 hover:bg-purple-100/50 dark:hover:bg-purple-900/30'
1225
- : 'hover:bg-zinc-50/50 dark:hover:bg-zinc-900/50'
1226
- }`}
1227
- >
1228
- <div className="flex flex-col gap-0.5 flex-1 min-w-0">
1229
- {/* Property name with type tooltip and mutation indicator */}
1230
- <div className="flex items-center gap-1.5">
1231
- {isMutated && (
1232
- <Tooltip>
1233
- <TooltipTrigger asChild>
1234
- <Badge variant="secondary" className="h-4 px-1 text-[9px] bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-700">
1235
- edited
1236
- </Badge>
1237
- </TooltipTrigger>
1238
- <TooltipContent>This property has been modified</TooltipContent>
1239
- </Tooltip>
1240
- )}
1241
- {parsed.ifcType ? (
1242
- <Tooltip>
1243
- <TooltipTrigger asChild>
1244
- <span className={`font-medium cursor-help break-words ${isMutated ? 'text-purple-600 dark:text-purple-400' : 'text-zinc-500 dark:text-zinc-400'}`}>
1245
- {decodedName}
1246
- </span>
1247
- </TooltipTrigger>
1248
- <TooltipContent side="top" className="text-[10px]">
1249
- <span className="text-zinc-400">{parsed.ifcType}</span>
1250
- </TooltipContent>
1251
- </Tooltip>
1252
- ) : (
1253
- <span className={`font-medium break-words ${isMutated ? 'text-purple-600 dark:text-purple-400' : 'text-zinc-500 dark:text-zinc-400'}`}>
1254
- {decodedName}
1255
- </span>
1256
- )}
1257
- </div>
1258
- {/* Property value - use PropertyEditor if editing enabled */}
1259
- {enableEditing && modelId && entityId ? (
1260
- <PropertyEditor
1261
- modelId={modelId}
1262
- entityId={entityId}
1263
- psetName={pset.name}
1264
- propName={prop.name}
1265
- currentValue={prop.value}
1266
- />
1267
- ) : (
1268
- <span className={`font-mono select-all break-words ${isMutated ? 'text-purple-900 dark:text-purple-100 font-semibold' : 'text-zinc-900 dark:text-zinc-100'}`}>
1269
- {parsed.displayValue}
1270
- </span>
1271
- )}
1272
- </div>
1273
- </div>
1274
- );
1275
- })}
1276
- </div>
1277
- </CollapsibleContent>
1278
- </Collapsible>
1279
- );
1280
- }
1281
-
1282
- /** Maps quantity type to friendly name for tooltip */
1283
- const QUANTITY_TYPE_NAMES: Record<number, string> = {
1284
- 0: 'Length',
1285
- 1: 'Area',
1286
- 2: 'Volume',
1287
- 3: 'Count',
1288
- 4: 'Weight',
1289
- 5: 'Time',
1290
- };
1291
-
1292
- function QuantitySetCard({ qset }: { qset: QuantitySet }) {
1293
- const formatValue = (value: number, type: number): string => {
1294
- const formatted = value.toLocaleString(undefined, { maximumFractionDigits: 3 });
1295
- switch (type) {
1296
- case 0: return `${formatted} m`;
1297
- case 1: return `${formatted} m²`;
1298
- case 2: return `${formatted} m³`;
1299
- case 3: return formatted;
1300
- case 4: return `${formatted} kg`;
1301
- case 5: return `${formatted} s`;
1302
- default: return formatted;
1303
- }
1304
- };
1305
-
1306
- return (
1307
- <Collapsible defaultOpen className="border-2 border-blue-200 dark:border-blue-800 bg-blue-50/20 dark:bg-blue-950/20 w-full max-w-full overflow-hidden">
1308
- <CollapsibleTrigger className="flex items-center gap-2 w-full p-2.5 hover:bg-blue-50 dark:hover:bg-blue-900/30 text-left transition-colors overflow-hidden">
1309
- <span className="font-bold text-xs text-blue-700 dark:text-blue-400 truncate flex-1 min-w-0">{decodeIfcString(qset.name)}</span>
1310
- <span className="text-[10px] font-mono bg-blue-100 dark:bg-blue-900/50 px-1.5 py-0.5 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300 shrink-0">{qset.quantities.length}</span>
1311
- </CollapsibleTrigger>
1312
- <CollapsibleContent>
1313
- <div className="border-t-2 border-blue-200 dark:border-blue-800 divide-y divide-blue-100 dark:divide-blue-900/30">
1314
- {qset.quantities.map((q: { name: string; value: number; type: number }) => {
1315
- const decodedName = decodeIfcString(q.name);
1316
- const typeName = QUANTITY_TYPE_NAMES[q.type];
1317
- return (
1318
- <div key={q.name} className="flex flex-col gap-0.5 px-3 py-2 text-xs hover:bg-blue-50/50 dark:hover:bg-blue-900/20">
1319
- {/* Quantity name with type tooltip */}
1320
- {typeName ? (
1321
- <Tooltip>
1322
- <TooltipTrigger asChild>
1323
- <span className="text-zinc-500 dark:text-zinc-400 font-medium cursor-help break-words">
1324
- {decodedName}
1325
- </span>
1326
- </TooltipTrigger>
1327
- <TooltipContent side="top" className="text-[10px]">
1328
- <span className="text-zinc-400">{typeName}</span>
1329
- </TooltipContent>
1330
- </Tooltip>
1331
- ) : (
1332
- <span className="text-zinc-500 dark:text-zinc-400 font-medium break-words">
1333
- {decodedName}
1334
- </span>
1335
- )}
1336
- {/* Quantity value */}
1337
- <span className="font-mono text-blue-700 dark:text-blue-400 select-all break-words">
1338
- {formatValue(q.value, q.type)}
1339
- </span>
1340
- </div>
1341
- );
1342
- })}
1343
- </div>
1344
- </CollapsibleContent>
1345
- </Collapsible>
1346
- );
1347
- }
1348
-
1349
- /** Model metadata panel - displays file info, schema version, entity counts, etc. */
1350
- function ModelMetadataPanel({ model }: { model: FederatedModel }) {
1351
- const dataStore = model.ifcDataStore;
1352
-
1353
- // Format file size
1354
- const formatFileSize = (bytes: number): string => {
1355
- if (bytes < 1024) return `${bytes} B`;
1356
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1357
- return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
1358
- };
1359
-
1360
- // Format date
1361
- const formatDate = (timestamp: number): string => {
1362
- return new Date(timestamp).toLocaleString();
1363
- };
1364
-
1365
- // Get IfcProject data if available
1366
- const projectData = useMemo(() => {
1367
- if (!dataStore?.spatialHierarchy?.project) return null;
1368
- const project = dataStore.spatialHierarchy.project;
1369
- const projectId = project.expressId;
1370
-
1371
- // Get project entity attributes
1372
- const name = dataStore.entities.getName(projectId);
1373
- const globalId = dataStore.entities.getGlobalId(projectId);
1374
- const description = dataStore.entities.getDescription(projectId);
1375
-
1376
- // Get project properties
1377
- const properties: PropertySet[] = [];
1378
- if (dataStore.properties) {
1379
- for (const pset of dataStore.properties.getForEntity(projectId)) {
1380
- properties.push({
1381
- name: pset.name,
1382
- properties: pset.properties.map(p => ({ name: p.name, value: p.value })),
1383
- });
1384
- }
1385
- }
1386
-
1387
- return { name, globalId, description, properties };
1388
- }, [dataStore]);
1389
-
1390
- // Count storeys and elements
1391
- const stats = useMemo(() => {
1392
- if (!dataStore?.spatialHierarchy) {
1393
- return { storeys: 0, elementsWithGeometry: 0 };
1394
- }
1395
- const storeys = dataStore.spatialHierarchy.byStorey.size;
1396
- let elementsWithGeometry = 0;
1397
- for (const elements of dataStore.spatialHierarchy.byStorey.values()) {
1398
- elementsWithGeometry += (elements as number[]).length;
1399
- }
1400
- return { storeys, elementsWithGeometry };
1401
- }, [dataStore]);
1402
-
1403
- return (
1404
- <div className="h-full flex flex-col border-l-2 border-zinc-200 dark:border-zinc-800 bg-white dark:bg-black">
1405
- {/* Header */}
1406
- <div className="p-4 border-b-2 border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-black space-y-3">
1407
- <div className="flex items-start gap-3">
1408
- <div className="p-2 border-2 border-primary/30 bg-primary/10 shrink-0 shadow-[2px_2px_0px_0px_rgba(0,0,0,0.1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.1)]">
1409
- <FileBox className="h-5 w-5 text-primary" />
1410
- </div>
1411
- <div className="flex-1 min-w-0 pt-0.5">
1412
- <h3 className="font-bold text-sm truncate uppercase tracking-tight text-zinc-900 dark:text-zinc-100">
1413
- {model.name}
1414
- </h3>
1415
- <p className="text-xs font-mono text-zinc-500 dark:text-zinc-400">IFC Model</p>
1416
- </div>
1417
- </div>
1418
-
1419
- {/* Schema badge */}
1420
- <div className="flex items-center gap-2">
1421
- <span className="text-[10px] font-mono bg-primary/10 border border-primary/30 px-2 py-1 text-primary font-bold uppercase">
1422
- {model.schemaVersion}
1423
- </span>
1424
- </div>
1425
- </div>
1426
-
1427
- <ScrollArea className="flex-1">
1428
- {/* File Information */}
1429
- <div className="border-b border-zinc-200 dark:border-zinc-800">
1430
- <div className="p-3 bg-zinc-50 dark:bg-zinc-900/50">
1431
- <h4 className="font-bold text-xs uppercase tracking-wide text-zinc-700 dark:text-zinc-300">
1432
- File Information
1433
- </h4>
1434
- </div>
1435
- <div className="divide-y divide-zinc-100 dark:divide-zinc-900">
1436
- <div className="flex items-center gap-3 px-3 py-2">
1437
- <HardDrive className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
1438
- <span className="text-xs text-zinc-500">File Size</span>
1439
- <span className="text-xs font-mono text-zinc-900 dark:text-zinc-100 ml-auto">
1440
- {formatFileSize(model.fileSize)}
1441
- </span>
1442
- </div>
1443
- <div className="flex items-center gap-3 px-3 py-2">
1444
- <Clock className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
1445
- <span className="text-xs text-zinc-500">Loaded At</span>
1446
- <span className="text-xs font-mono text-zinc-900 dark:text-zinc-100 ml-auto">
1447
- {formatDate(model.loadedAt)}
1448
- </span>
1449
- </div>
1450
- {dataStore && (
1451
- <div className="flex items-center gap-3 px-3 py-2">
1452
- <Clock className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
1453
- <span className="text-xs text-zinc-500">Parse Time</span>
1454
- <span className="text-xs font-mono text-zinc-900 dark:text-zinc-100 ml-auto">
1455
- {dataStore.parseTime.toFixed(0)} ms
1456
- </span>
1457
- </div>
1458
- )}
1459
- </div>
1460
- </div>
1461
-
1462
- {/* Entity Statistics */}
1463
- <div className="border-b border-zinc-200 dark:border-zinc-800">
1464
- <div className="p-3 bg-zinc-50 dark:bg-zinc-900/50">
1465
- <h4 className="font-bold text-xs uppercase tracking-wide text-zinc-700 dark:text-zinc-300">
1466
- Statistics
1467
- </h4>
1468
- </div>
1469
- <div className="divide-y divide-zinc-100 dark:divide-zinc-900">
1470
- <div className="flex items-center gap-3 px-3 py-2">
1471
- <Database className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
1472
- <span className="text-xs text-zinc-500">Total Entities</span>
1473
- <span className="text-xs font-mono text-zinc-900 dark:text-zinc-100 ml-auto">
1474
- {dataStore?.entityCount?.toLocaleString() ?? 'N/A'}
1475
- </span>
1476
- </div>
1477
- <div className="flex items-center gap-3 px-3 py-2">
1478
- <Layers className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
1479
- <span className="text-xs text-zinc-500">Building Storeys</span>
1480
- <span className="text-xs font-mono text-zinc-900 dark:text-zinc-100 ml-auto">
1481
- {stats.storeys}
1482
- </span>
1483
- </div>
1484
- <div className="flex items-center gap-3 px-3 py-2">
1485
- <Building2 className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
1486
- <span className="text-xs text-zinc-500">Elements with Geometry</span>
1487
- <span className="text-xs font-mono text-zinc-900 dark:text-zinc-100 ml-auto">
1488
- {stats.elementsWithGeometry.toLocaleString()}
1489
- </span>
1490
- </div>
1491
- <div className="flex items-center gap-3 px-3 py-2">
1492
- <Hash className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
1493
- <span className="text-xs text-zinc-500">Max Express ID</span>
1494
- <span className="text-xs font-mono text-zinc-900 dark:text-zinc-100 ml-auto">
1495
- {model.maxExpressId.toLocaleString()}
1496
- </span>
1497
- </div>
1498
- </div>
1499
- </div>
1500
-
1501
- {/* IfcProject Data */}
1502
- {projectData && (
1503
- <div className="border-b border-zinc-200 dark:border-zinc-800">
1504
- <div className="p-3 bg-zinc-50 dark:bg-zinc-900/50">
1505
- <h4 className="font-bold text-xs uppercase tracking-wide text-zinc-700 dark:text-zinc-300">
1506
- Project Information
1507
- </h4>
1508
- </div>
1509
- <div className="divide-y divide-zinc-100 dark:divide-zinc-900">
1510
- {projectData.name && (
1511
- <div className="flex items-center gap-3 px-3 py-2">
1512
- <Tag className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
1513
- <span className="text-xs text-zinc-500">Name</span>
1514
- <span className="text-xs font-medium text-zinc-900 dark:text-zinc-100 ml-auto truncate max-w-[60%]">
1515
- {projectData.name}
1516
- </span>
1517
- </div>
1518
- )}
1519
- {projectData.description && (
1520
- <div className="flex items-start gap-3 px-3 py-2">
1521
- <FileText className="h-3.5 w-3.5 text-zinc-400 shrink-0 mt-0.5" />
1522
- <span className="text-xs text-zinc-500 shrink-0">Description</span>
1523
- <span className="text-xs text-zinc-900 dark:text-zinc-100 ml-auto text-right max-w-[60%]">
1524
- {projectData.description}
1525
- </span>
1526
- </div>
1527
- )}
1528
- {projectData.globalId && (
1529
- <div className="flex items-center gap-3 px-3 py-2">
1530
- <Hash className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
1531
- <span className="text-xs text-zinc-500">GlobalId</span>
1532
- <code className="text-[10px] font-mono text-zinc-600 dark:text-zinc-400 ml-auto truncate max-w-[60%]">
1533
- {projectData.globalId}
1534
- </code>
1535
- </div>
1536
- )}
1537
- </div>
1538
-
1539
- {/* Project Properties */}
1540
- {projectData.properties.length > 0 && (
1541
- <div className="p-3 pt-0 space-y-2">
1542
- {projectData.properties.map((pset) => (
1543
- <PropertySetCard key={pset.name} pset={pset} />
1544
- ))}
1545
- </div>
1546
- )}
1547
- </div>
1548
- )}
1549
- </ScrollArea>
1550
- </div>
1551
- );
1552
- }