@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.
- package/CHANGELOG.md +78 -0
- package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
- package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
- package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
- package/dist/assets/index-yTqs8kgX.css +1 -0
- package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
- package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +18 -15
- package/src/components/viewer/BCFPanel.tsx +7 -789
- package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
- package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
- package/src/components/viewer/HierarchyPanel.tsx +110 -842
- package/src/components/viewer/IDSExportDialog.tsx +281 -0
- package/src/components/viewer/IDSPanel.tsx +126 -17
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
- package/src/components/viewer/LensPanel.tsx +603 -0
- package/src/components/viewer/MainToolbar.tsx +188 -21
- package/src/components/viewer/PropertiesPanel.tsx +171 -663
- package/src/components/viewer/PropertyEditor.tsx +866 -77
- package/src/components/viewer/Section2DPanel.tsx +76 -2648
- package/src/components/viewer/ToolOverlays.tsx +3 -1097
- package/src/components/viewer/ViewerLayout.tsx +132 -45
- package/src/components/viewer/Viewport.tsx +237 -1659
- package/src/components/viewer/ViewportContainer.tsx +11 -3
- package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
- package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
- package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
- package/src/components/viewer/hierarchy/types.ts +54 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
- package/src/components/viewer/lists/ListBuilder.tsx +486 -0
- package/src/components/viewer/lists/ListPanel.tsx +540 -0
- package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
- package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
- package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
- package/src/components/viewer/properties/DocumentCard.tsx +89 -0
- package/src/components/viewer/properties/MaterialCard.tsx +201 -0
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
- package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
- package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
- package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
- package/src/components/viewer/properties/encodingUtils.ts +29 -0
- package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
- package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
- package/src/components/viewer/tools/SectionPanel.tsx +183 -0
- package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
- package/src/components/viewer/tools/formatDistance.ts +18 -0
- package/src/components/viewer/tools/sectionConstants.ts +14 -0
- package/src/components/viewer/useAnimationLoop.ts +166 -0
- package/src/components/viewer/useGeometryStreaming.ts +398 -0
- package/src/components/viewer/useKeyboardControls.ts +221 -0
- package/src/components/viewer/useMouseControls.ts +1009 -0
- package/src/components/viewer/useRenderUpdates.ts +165 -0
- package/src/components/viewer/useTouchControls.ts +245 -0
- package/src/hooks/ids/idsColorSystem.ts +125 -0
- package/src/hooks/ids/idsDataAccessor.ts +237 -0
- package/src/hooks/ids/idsExportService.ts +444 -0
- package/src/hooks/useBCF.ts +7 -0
- package/src/hooks/useDrawingExport.ts +627 -0
- package/src/hooks/useDrawingGeneration.ts +627 -0
- package/src/hooks/useFloorplanView.ts +108 -0
- package/src/hooks/useIDS.ts +270 -463
- package/src/hooks/useIfc.ts +26 -1628
- package/src/hooks/useIfcFederation.ts +803 -0
- package/src/hooks/useIfcLoader.ts +508 -0
- package/src/hooks/useIfcServer.ts +465 -0
- package/src/hooks/useKeyboardShortcuts.ts +1 -1
- package/src/hooks/useLens.ts +129 -0
- package/src/hooks/useMeasure2D.ts +365 -0
- package/src/hooks/useViewControls.ts +218 -0
- package/src/lib/ifc4-pset-definitions.test.ts +161 -0
- package/src/lib/ifc4-pset-definitions.ts +621 -0
- package/src/lib/ifc4-qto-definitions.ts +315 -0
- package/src/lib/lens/adapter.ts +138 -0
- package/src/lib/lens/index.ts +5 -0
- package/src/lib/lists/adapter.ts +69 -0
- package/src/lib/lists/index.ts +28 -0
- package/src/lib/lists/persistence.ts +64 -0
- package/src/services/fs-cache.ts +1 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/index.ts +38 -2
- package/src/store/slices/cameraSlice.ts +14 -1
- package/src/store/slices/dataSlice.ts +14 -1
- package/src/store/slices/lensSlice.ts +184 -0
- package/src/store/slices/listSlice.ts +74 -0
- package/src/store/slices/pinboardSlice.ts +114 -0
- package/src/store/types.ts +5 -0
- package/src/utils/ifcConfig.ts +16 -3
- package/src/utils/serverDataModel.ts +64 -101
- package/src/vite-env.d.ts +3 -0
- package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
- 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
|
+
}
|