@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
@@ -0,0 +1,193 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * ListResultsTable - Virtualized table displaying list execution results
7
+ *
8
+ * PERF: Uses @tanstack/react-virtual for efficient rendering of large result sets.
9
+ * Only renders visible rows, supports 100K+ rows smoothly.
10
+ * Clicking a row selects the entity in the 3D viewer.
11
+ */
12
+
13
+ import React, { useCallback, useMemo, useRef, useState } from 'react';
14
+ import { useVirtualizer } from '@tanstack/react-virtual';
15
+ import { ArrowUp, ArrowDown, Search } from 'lucide-react';
16
+ import { Input } from '@/components/ui/input';
17
+ import { useViewerStore } from '@/store';
18
+ import type { ListResult, ListRow, CellValue } from '@ifc-lite/lists';
19
+ import { cn } from '@/lib/utils';
20
+
21
+ interface ListResultsTableProps {
22
+ result: ListResult;
23
+ }
24
+
25
+ export function ListResultsTable({ result }: ListResultsTableProps) {
26
+ const parentRef = useRef<HTMLDivElement>(null);
27
+ const [searchQuery, setSearchQuery] = useState('');
28
+ const [sortCol, setSortCol] = useState<number | null>(null);
29
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
30
+
31
+ const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
32
+ const setSelectedEntity = useViewerStore((s) => s.setSelectedEntity);
33
+ const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
34
+
35
+ // Filter rows by search query
36
+ const filteredRows = useMemo(() => {
37
+ if (!searchQuery) return result.rows;
38
+ const q = searchQuery.toLowerCase();
39
+ return result.rows.filter(row =>
40
+ row.values.some(v => v !== null && String(v).toLowerCase().includes(q))
41
+ );
42
+ }, [result.rows, searchQuery]);
43
+
44
+ // Sort rows
45
+ const sortedRows = useMemo(() => {
46
+ if (sortCol === null) return filteredRows;
47
+ const sorted = [...filteredRows];
48
+ sorted.sort((a, b) => {
49
+ const va = a.values[sortCol];
50
+ const vb = b.values[sortCol];
51
+ return compareCells(va, vb) * (sortDir === 'asc' ? 1 : -1);
52
+ });
53
+ return sorted;
54
+ }, [filteredRows, sortCol, sortDir]);
55
+
56
+ const handleHeaderClick = useCallback((colIndex: number) => {
57
+ if (sortCol === colIndex) {
58
+ setSortDir(prev => prev === 'asc' ? 'desc' : 'asc');
59
+ } else {
60
+ setSortCol(colIndex);
61
+ setSortDir('asc');
62
+ }
63
+ }, [sortCol]);
64
+
65
+ const handleRowClick = useCallback((row: ListRow) => {
66
+ setSelectedEntity({ modelId: row.modelId, expressId: row.entityId });
67
+ // For single-model, selectedEntityId is the expressId
68
+ // For multi-model, we'd need the global ID, but we set expressId for now
69
+ setSelectedEntityId(row.entityId);
70
+ }, [setSelectedEntity, setSelectedEntityId]);
71
+
72
+ // Column widths
73
+ const columnWidths = useMemo(() => {
74
+ return result.columns.map(col => {
75
+ const label = col.label ?? col.propertyName;
76
+ // Estimate width: min 80px, max 250px, based on header + content
77
+ return Math.max(80, Math.min(250, label.length * 8 + 40));
78
+ });
79
+ }, [result.columns]);
80
+
81
+ const totalWidth = useMemo(() => columnWidths.reduce((a, b) => a + b, 0), [columnWidths]);
82
+
83
+ const virtualizer = useVirtualizer({
84
+ count: sortedRows.length,
85
+ getScrollElement: () => parentRef.current,
86
+ estimateSize: () => 28,
87
+ overscan: 20,
88
+ });
89
+
90
+ return (
91
+ <div className="flex-1 flex flex-col min-h-0">
92
+ {/* Search bar */}
93
+ <div className="flex items-center gap-2 px-3 py-1.5 border-b">
94
+ <Search className="h-3.5 w-3.5 text-muted-foreground" />
95
+ <Input
96
+ placeholder="Filter results..."
97
+ value={searchQuery}
98
+ onChange={e => setSearchQuery(e.target.value)}
99
+ className="h-7 text-xs border-0 shadow-none focus-visible:ring-0 px-0"
100
+ />
101
+ <span className="text-xs text-muted-foreground whitespace-nowrap">
102
+ {sortedRows.length}{searchQuery ? ` / ${result.rows.length}` : ''} rows
103
+ </span>
104
+ </div>
105
+
106
+ {/* Table */}
107
+ <div ref={parentRef} className="flex-1 overflow-auto min-h-0">
108
+ <div style={{ minWidth: totalWidth }}>
109
+ {/* Header */}
110
+ <div className="flex sticky top-0 bg-muted/80 backdrop-blur-sm border-b z-10">
111
+ {result.columns.map((col, colIdx) => (
112
+ <button
113
+ key={col.id}
114
+ className="flex items-center gap-1 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border-r border-border/50 shrink-0"
115
+ style={{ width: columnWidths[colIdx] }}
116
+ onClick={() => handleHeaderClick(colIdx)}
117
+ >
118
+ <span className="truncate">
119
+ {col.label ?? col.propertyName}
120
+ </span>
121
+ {sortCol === colIdx && (
122
+ sortDir === 'asc'
123
+ ? <ArrowUp className="h-3 w-3 shrink-0" />
124
+ : <ArrowDown className="h-3 w-3 shrink-0" />
125
+ )}
126
+ </button>
127
+ ))}
128
+ </div>
129
+
130
+ {/* Virtualized rows */}
131
+ <div
132
+ style={{
133
+ height: `${virtualizer.getTotalSize()}px`,
134
+ width: '100%',
135
+ position: 'relative',
136
+ }}
137
+ >
138
+ {virtualizer.getVirtualItems().map(virtualRow => {
139
+ const row = sortedRows[virtualRow.index];
140
+ const isSelected = row.entityId === selectedEntityId;
141
+
142
+ return (
143
+ <div
144
+ key={virtualRow.key}
145
+ data-index={virtualRow.index}
146
+ ref={virtualizer.measureElement}
147
+ className={cn(
148
+ 'flex absolute top-0 left-0 w-full border-b border-border/30 cursor-pointer hover:bg-muted/40',
149
+ isSelected && 'bg-primary/10'
150
+ )}
151
+ style={{
152
+ transform: `translateY(${virtualRow.start}px)`,
153
+ }}
154
+ onClick={() => handleRowClick(row)}
155
+ >
156
+ {row.values.map((value, colIdx) => (
157
+ <div
158
+ key={colIdx}
159
+ className="px-2 py-1 text-xs truncate border-r border-border/20 shrink-0"
160
+ style={{ width: columnWidths[colIdx] }}
161
+ title={value !== null ? String(value) : ''}
162
+ >
163
+ {formatCellValue(value)}
164
+ </div>
165
+ ))}
166
+ </div>
167
+ );
168
+ })}
169
+ </div>
170
+ </div>
171
+ </div>
172
+ </div>
173
+ );
174
+ }
175
+
176
+ function formatCellValue(value: CellValue): string {
177
+ if (value === null || value === undefined) return '';
178
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
179
+ if (typeof value === 'number') {
180
+ // Format numbers: integers as-is, decimals with up to 4 decimal places
181
+ if (Number.isInteger(value)) return String(value);
182
+ return value.toFixed(4).replace(/\.?0+$/, '');
183
+ }
184
+ return String(value);
185
+ }
186
+
187
+ function compareCells(a: CellValue, b: CellValue): number {
188
+ if (a === null && b === null) return 0;
189
+ if (a === null) return -1;
190
+ if (b === null) return 1;
191
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
192
+ return String(a).localeCompare(String(b));
193
+ }
@@ -0,0 +1,70 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Classification display component for IFC element classifications.
7
+ */
8
+
9
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
10
+ import { Tag } from 'lucide-react';
11
+ import type { ClassificationInfo } from '@ifc-lite/parser';
12
+
13
+ export function ClassificationCard({ classification }: { classification: ClassificationInfo }) {
14
+ const displayName = classification.identification || classification.name || 'Unknown';
15
+ const systemName = classification.system;
16
+
17
+ return (
18
+ <Collapsible defaultOpen className="border-2 border-emerald-200 dark:border-emerald-800 bg-emerald-50/20 dark:bg-emerald-950/20 w-full max-w-full overflow-hidden">
19
+ <CollapsibleTrigger className="flex items-center gap-2 w-full p-2.5 hover:bg-emerald-50 dark:hover:bg-emerald-900/30 text-left transition-colors overflow-hidden">
20
+ <Tag className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400 shrink-0" />
21
+ <span className="font-bold text-xs text-emerald-700 dark:text-emerald-400 truncate flex-1 min-w-0">
22
+ {systemName || 'Classification'}
23
+ </span>
24
+ <span className="text-[10px] font-mono bg-emerald-100 dark:bg-emerald-900/50 px-1.5 py-0.5 border border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300 shrink-0">
25
+ {displayName}
26
+ </span>
27
+ </CollapsibleTrigger>
28
+ <CollapsibleContent>
29
+ <div className="border-t-2 border-emerald-200 dark:border-emerald-800 divide-y divide-emerald-100 dark:divide-emerald-900/30">
30
+ {classification.identification && (
31
+ <div className="flex flex-col gap-0.5 px-3 py-2 text-xs hover:bg-emerald-50/50 dark:hover:bg-emerald-900/20">
32
+ <span className="text-zinc-500 dark:text-zinc-400 font-medium">Identification</span>
33
+ <span className="font-mono text-emerald-700 dark:text-emerald-400 select-all break-words">{classification.identification}</span>
34
+ </div>
35
+ )}
36
+ {classification.name && (
37
+ <div className="flex flex-col gap-0.5 px-3 py-2 text-xs hover:bg-emerald-50/50 dark:hover:bg-emerald-900/20">
38
+ <span className="text-zinc-500 dark:text-zinc-400 font-medium">Name</span>
39
+ <span className="font-mono text-emerald-700 dark:text-emerald-400 select-all break-words">{classification.name}</span>
40
+ </div>
41
+ )}
42
+ {classification.system && (
43
+ <div className="flex flex-col gap-0.5 px-3 py-2 text-xs hover:bg-emerald-50/50 dark:hover:bg-emerald-900/20">
44
+ <span className="text-zinc-500 dark:text-zinc-400 font-medium">System</span>
45
+ <span className="font-mono text-emerald-700 dark:text-emerald-400 select-all break-words">{classification.system}</span>
46
+ </div>
47
+ )}
48
+ {classification.location && (
49
+ <div className="flex flex-col gap-0.5 px-3 py-2 text-xs hover:bg-emerald-50/50 dark:hover:bg-emerald-900/20">
50
+ <span className="text-zinc-500 dark:text-zinc-400 font-medium">Location</span>
51
+ <span className="font-mono text-emerald-700 dark:text-emerald-400 select-all break-words">{classification.location}</span>
52
+ </div>
53
+ )}
54
+ {classification.path && classification.path.length > 0 && (
55
+ <div className="flex flex-col gap-0.5 px-3 py-2 text-xs hover:bg-emerald-50/50 dark:hover:bg-emerald-900/20">
56
+ <span className="text-zinc-500 dark:text-zinc-400 font-medium">Path</span>
57
+ <span className="font-mono text-emerald-700 dark:text-emerald-400 select-all break-words">{classification.path.join(' > ')}</span>
58
+ </div>
59
+ )}
60
+ {classification.description && (
61
+ <div className="flex flex-col gap-0.5 px-3 py-2 text-xs hover:bg-emerald-50/50 dark:hover:bg-emerald-900/20">
62
+ <span className="text-zinc-500 dark:text-zinc-400 font-medium">Description</span>
63
+ <span className="font-mono text-emerald-700 dark:text-emerald-400 select-all break-words">{classification.description}</span>
64
+ </div>
65
+ )}
66
+ </div>
67
+ </CollapsibleContent>
68
+ </Collapsible>
69
+ );
70
+ }
@@ -0,0 +1,49 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Coordinate display components for entity position information.
7
+ */
8
+
9
+ import { Copy, Check } from 'lucide-react';
10
+
11
+ /** Inline coordinate value with dim axis label */
12
+ export function CoordVal({ axis, value }: { axis: string; value: number }) {
13
+ return (
14
+ <span className="whitespace-nowrap"><span className="opacity-50">{axis}</span>{'\u2009'}{value.toFixed(3)}</span>
15
+ );
16
+ }
17
+
18
+ /** Copyable coordinate row: label + values with copy button hugging the values */
19
+ export function CoordRow({ label, values, primary, copyLabel, coordCopied, onCopy }: {
20
+ label: string;
21
+ values: { axis: string; value: number }[];
22
+ primary?: boolean;
23
+ copyLabel: string;
24
+ coordCopied: string | null;
25
+ onCopy: (label: string, text: string) => void;
26
+ }) {
27
+ const isCopied = coordCopied === copyLabel;
28
+ const copyText = values.map(v => v.value.toFixed(3)).join(', ');
29
+ return (
30
+ <div className="flex items-start gap-1.5 group min-w-0">
31
+ {label && (
32
+ <span className={`text-[9px] font-medium uppercase tracking-wider w-[34px] shrink-0 pt-px ${primary ? 'text-muted-foreground' : 'text-muted-foreground/50'}`}>
33
+ {label}
34
+ </span>
35
+ )}
36
+ <span className={`font-mono text-[10px] min-w-0 tabular-nums leading-relaxed ${primary ? 'text-foreground' : 'text-muted-foreground/60'}`}>
37
+ {values.map((v, i) => (
38
+ <span key={v.axis}>{i > 0 && <>{' '}</>}<CoordVal axis={v.axis} value={v.value} /></span>
39
+ ))}
40
+ </span>
41
+ <button
42
+ 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'}`}
43
+ onClick={(e) => { e.stopPropagation(); onCopy(copyLabel, copyText); }}
44
+ >
45
+ {isCopied ? <Check className="h-2.5 w-2.5" /> : <Copy className="h-2.5 w-2.5" />}
46
+ </button>
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,89 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Document display component for IFC document references.
7
+ */
8
+
9
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
10
+ import { FileText } from 'lucide-react';
11
+ import type { DocumentInfo } from '@ifc-lite/parser';
12
+
13
+ export function DocumentCard({ document }: { document: DocumentInfo }) {
14
+ const displayName = document.name || document.identification || 'Document';
15
+ const isUrl = document.location?.startsWith('http://') || document.location?.startsWith('https://');
16
+
17
+ return (
18
+ <Collapsible defaultOpen className="border-2 border-sky-200 dark:border-sky-800 bg-sky-50/20 dark:bg-sky-950/20 w-full max-w-full overflow-hidden">
19
+ <CollapsibleTrigger className="flex items-center gap-2 w-full p-2.5 hover:bg-sky-50 dark:hover:bg-sky-900/30 text-left transition-colors overflow-hidden">
20
+ <FileText className="h-3.5 w-3.5 text-sky-600 dark:text-sky-400 shrink-0" />
21
+ <span className="font-bold text-xs text-sky-700 dark:text-sky-400 truncate flex-1 min-w-0">
22
+ {displayName}
23
+ </span>
24
+ {document.revision && (
25
+ <span className="text-[10px] font-mono bg-sky-100 dark:bg-sky-900/50 px-1.5 py-0.5 border border-sky-200 dark:border-sky-800 text-sky-700 dark:text-sky-300 shrink-0">
26
+ {document.revision}
27
+ </span>
28
+ )}
29
+ </CollapsibleTrigger>
30
+ <CollapsibleContent>
31
+ <div className="border-t-2 border-sky-200 dark:border-sky-800 divide-y divide-sky-100 dark:divide-sky-900/30">
32
+ {document.identification && (
33
+ <div className="flex flex-col gap-0.5 px-3 py-2 text-xs hover:bg-sky-50/50 dark:hover:bg-sky-900/20">
34
+ <span className="text-zinc-500 dark:text-zinc-400 font-medium">Identification</span>
35
+ <span className="font-mono text-sky-700 dark:text-sky-400 select-all break-words">{document.identification}</span>
36
+ </div>
37
+ )}
38
+ {document.name && (
39
+ <div className="flex flex-col gap-0.5 px-3 py-2 text-xs hover:bg-sky-50/50 dark:hover:bg-sky-900/20">
40
+ <span className="text-zinc-500 dark:text-zinc-400 font-medium">Name</span>
41
+ <span className="font-mono text-sky-700 dark:text-sky-400 select-all break-words">{document.name}</span>
42
+ </div>
43
+ )}
44
+ {document.description && (
45
+ <div className="flex flex-col gap-0.5 px-3 py-2 text-xs hover:bg-sky-50/50 dark:hover:bg-sky-900/20">
46
+ <span className="text-zinc-500 dark:text-zinc-400 font-medium">Description</span>
47
+ <span className="font-mono text-sky-700 dark:text-sky-400 select-all break-words">{document.description}</span>
48
+ </div>
49
+ )}
50
+ {document.location && (
51
+ <div className="flex flex-col gap-0.5 px-3 py-2 text-xs hover:bg-sky-50/50 dark:hover:bg-sky-900/20">
52
+ <span className="text-zinc-500 dark:text-zinc-400 font-medium">Location</span>
53
+ {isUrl ? (
54
+ <a
55
+ href={document.location}
56
+ target="_blank"
57
+ rel="noopener noreferrer"
58
+ className="font-mono text-sky-600 dark:text-sky-400 underline hover:text-sky-800 dark:hover:text-sky-300 break-all"
59
+ >
60
+ {document.location}
61
+ </a>
62
+ ) : (
63
+ <span className="font-mono text-sky-700 dark:text-sky-400 select-all break-words">{document.location}</span>
64
+ )}
65
+ </div>
66
+ )}
67
+ {document.purpose && (
68
+ <div className="flex flex-col gap-0.5 px-3 py-2 text-xs hover:bg-sky-50/50 dark:hover:bg-sky-900/20">
69
+ <span className="text-zinc-500 dark:text-zinc-400 font-medium">Purpose</span>
70
+ <span className="font-mono text-sky-700 dark:text-sky-400 select-all break-words">{document.purpose}</span>
71
+ </div>
72
+ )}
73
+ {document.intendedUse && (
74
+ <div className="flex flex-col gap-0.5 px-3 py-2 text-xs hover:bg-sky-50/50 dark:hover:bg-sky-900/20">
75
+ <span className="text-zinc-500 dark:text-zinc-400 font-medium">Intended Use</span>
76
+ <span className="font-mono text-sky-700 dark:text-sky-400 select-all break-words">{document.intendedUse}</span>
77
+ </div>
78
+ )}
79
+ {document.revision && (
80
+ <div className="flex flex-col gap-0.5 px-3 py-2 text-xs hover:bg-sky-50/50 dark:hover:bg-sky-900/20">
81
+ <span className="text-zinc-500 dark:text-zinc-400 font-medium">Revision</span>
82
+ <span className="font-mono text-sky-700 dark:text-sky-400 select-all break-words">{document.revision}</span>
83
+ </div>
84
+ )}
85
+ </div>
86
+ </CollapsibleContent>
87
+ </Collapsible>
88
+ );
89
+ }
@@ -0,0 +1,201 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Material display component for IFC element materials.
7
+ * Handles all IFC material types: direct, layer sets, profile sets,
8
+ * constituent sets, and material lists.
9
+ */
10
+
11
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
12
+ import { Layers } from 'lucide-react';
13
+ import type { MaterialInfo } from '@ifc-lite/parser';
14
+
15
+ const TYPE_LABELS: Record<string, string> = {
16
+ Material: 'Material',
17
+ MaterialLayerSet: 'Layer Set',
18
+ MaterialProfileSet: 'Profile Set',
19
+ MaterialConstituentSet: 'Constituent Set',
20
+ MaterialList: 'Material List',
21
+ };
22
+
23
+ export function MaterialCard({ material }: { material: MaterialInfo }) {
24
+ const typeLabel = TYPE_LABELS[material.type] || material.type;
25
+ const displayName = material.name || typeLabel;
26
+
27
+ return (
28
+ <Collapsible defaultOpen className="border-2 border-amber-200 dark:border-amber-800 bg-amber-50/20 dark:bg-amber-950/20 w-full max-w-full overflow-hidden">
29
+ <CollapsibleTrigger className="flex items-center gap-2 w-full p-2.5 hover:bg-amber-50 dark:hover:bg-amber-900/30 text-left transition-colors overflow-hidden">
30
+ <Layers className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400 shrink-0" />
31
+ <span className="font-bold text-xs text-amber-700 dark:text-amber-400 truncate flex-1 min-w-0">
32
+ {displayName}
33
+ </span>
34
+ <span className="text-[10px] font-mono bg-amber-100 dark:bg-amber-900/50 px-1.5 py-0.5 border border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300 shrink-0">
35
+ {typeLabel}
36
+ </span>
37
+ </CollapsibleTrigger>
38
+ <CollapsibleContent>
39
+ <div className="border-t-2 border-amber-200 dark:border-amber-800 divide-y divide-amber-100 dark:divide-amber-900/30">
40
+ {/* Direct material */}
41
+ {material.type === 'Material' && (
42
+ <>
43
+ {material.name && (
44
+ <MaterialRow label="Name" value={material.name} />
45
+ )}
46
+ {material.description && (
47
+ <MaterialRow label="Description" value={material.description} />
48
+ )}
49
+ </>
50
+ )}
51
+
52
+ {/* Layer Set */}
53
+ {material.type === 'MaterialLayerSet' && material.layers && (
54
+ <>
55
+ {material.name && <MaterialRow label="Set Name" value={material.name} />}
56
+ {material.layers.map((layer, i) => (
57
+ <div key={i} className="px-3 py-2 text-xs hover:bg-amber-50/50 dark:hover:bg-amber-900/20">
58
+ <div className="flex items-center gap-2 mb-1">
59
+ <span className="font-medium text-amber-700 dark:text-amber-400">
60
+ Layer {i + 1}
61
+ </span>
62
+ {layer.thickness !== undefined && (
63
+ <span className="text-[10px] font-mono bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 border border-amber-200 dark:border-amber-800 text-amber-600 dark:text-amber-300">
64
+ {formatThickness(layer.thickness)}
65
+ </span>
66
+ )}
67
+ </div>
68
+ <div className="grid grid-cols-[minmax(60px,auto)_1fr] gap-x-2 gap-y-0.5 ml-2">
69
+ {layer.materialName && (
70
+ <>
71
+ <span className="text-zinc-400">Material</span>
72
+ <span className="font-mono text-amber-700 dark:text-amber-400 break-words">{layer.materialName}</span>
73
+ </>
74
+ )}
75
+ {layer.name && (
76
+ <>
77
+ <span className="text-zinc-400">Name</span>
78
+ <span className="font-mono text-amber-700 dark:text-amber-400 break-words">{layer.name}</span>
79
+ </>
80
+ )}
81
+ {layer.category && (
82
+ <>
83
+ <span className="text-zinc-400">Category</span>
84
+ <span className="font-mono text-amber-700 dark:text-amber-400 break-words">{layer.category}</span>
85
+ </>
86
+ )}
87
+ {layer.isVentilated && (
88
+ <>
89
+ <span className="text-zinc-400">Ventilated</span>
90
+ <span className="font-mono text-amber-700 dark:text-amber-400">Yes</span>
91
+ </>
92
+ )}
93
+ </div>
94
+ </div>
95
+ ))}
96
+ </>
97
+ )}
98
+
99
+ {/* Profile Set */}
100
+ {material.type === 'MaterialProfileSet' && material.profiles && (
101
+ <>
102
+ {material.name && <MaterialRow label="Set Name" value={material.name} />}
103
+ {material.profiles.map((profile, i) => (
104
+ <div key={i} className="px-3 py-2 text-xs hover:bg-amber-50/50 dark:hover:bg-amber-900/20">
105
+ <div className="flex items-center gap-2 mb-1">
106
+ <span className="font-medium text-amber-700 dark:text-amber-400">
107
+ Profile {i + 1}
108
+ </span>
109
+ </div>
110
+ <div className="grid grid-cols-[minmax(60px,auto)_1fr] gap-x-2 gap-y-0.5 ml-2">
111
+ {profile.materialName && (
112
+ <>
113
+ <span className="text-zinc-400">Material</span>
114
+ <span className="font-mono text-amber-700 dark:text-amber-400 break-words">{profile.materialName}</span>
115
+ </>
116
+ )}
117
+ {profile.name && (
118
+ <>
119
+ <span className="text-zinc-400">Name</span>
120
+ <span className="font-mono text-amber-700 dark:text-amber-400 break-words">{profile.name}</span>
121
+ </>
122
+ )}
123
+ {profile.category && (
124
+ <>
125
+ <span className="text-zinc-400">Category</span>
126
+ <span className="font-mono text-amber-700 dark:text-amber-400 break-words">{profile.category}</span>
127
+ </>
128
+ )}
129
+ </div>
130
+ </div>
131
+ ))}
132
+ </>
133
+ )}
134
+
135
+ {/* Constituent Set */}
136
+ {material.type === 'MaterialConstituentSet' && material.constituents && (
137
+ <>
138
+ {material.name && <MaterialRow label="Set Name" value={material.name} />}
139
+ {material.constituents.map((constituent, i) => (
140
+ <div key={i} className="px-3 py-2 text-xs hover:bg-amber-50/50 dark:hover:bg-amber-900/20">
141
+ <div className="flex items-center gap-2 mb-1">
142
+ <span className="font-medium text-amber-700 dark:text-amber-400">
143
+ {constituent.name || `Constituent ${i + 1}`}
144
+ </span>
145
+ {constituent.fraction !== undefined && (
146
+ <span className="text-[10px] font-mono bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 border border-amber-200 dark:border-amber-800 text-amber-600 dark:text-amber-300">
147
+ {(constituent.fraction * 100).toFixed(1)}%
148
+ </span>
149
+ )}
150
+ </div>
151
+ <div className="grid grid-cols-[minmax(60px,auto)_1fr] gap-x-2 gap-y-0.5 ml-2">
152
+ {constituent.materialName && (
153
+ <>
154
+ <span className="text-zinc-400">Material</span>
155
+ <span className="font-mono text-amber-700 dark:text-amber-400 break-words">{constituent.materialName}</span>
156
+ </>
157
+ )}
158
+ {constituent.category && (
159
+ <>
160
+ <span className="text-zinc-400">Category</span>
161
+ <span className="font-mono text-amber-700 dark:text-amber-400 break-words">{constituent.category}</span>
162
+ </>
163
+ )}
164
+ </div>
165
+ </div>
166
+ ))}
167
+ </>
168
+ )}
169
+
170
+ {/* Material List */}
171
+ {material.type === 'MaterialList' && material.materials && (
172
+ <>
173
+ {material.materials.map((name, i) => (
174
+ <MaterialRow key={i} label={`Material ${i + 1}`} value={name} />
175
+ ))}
176
+ </>
177
+ )}
178
+ </div>
179
+ </CollapsibleContent>
180
+ </Collapsible>
181
+ );
182
+ }
183
+
184
+ function MaterialRow({ label, value }: { label: string; value: string }) {
185
+ return (
186
+ <div className="flex flex-col gap-0.5 px-3 py-2 text-xs hover:bg-amber-50/50 dark:hover:bg-amber-900/20">
187
+ <span className="text-zinc-500 dark:text-zinc-400 font-medium">{label}</span>
188
+ <span className="font-mono text-amber-700 dark:text-amber-400 select-all break-words">{value}</span>
189
+ </div>
190
+ );
191
+ }
192
+
193
+ function formatThickness(thickness: number): string {
194
+ if (thickness <= 0) return `${thickness.toFixed(1)} m`;
195
+ if (thickness >= 1) {
196
+ return `${thickness.toFixed(1)} m`;
197
+ }
198
+ // Show in mm for sub-meter thicknesses
199
+ const mm = thickness * 1000;
200
+ return `${mm.toFixed(1)} mm`;
201
+ }