@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,239 @@
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
+ * BCFTopicList - Topic list with filtering and sorting for the BCF panel.
7
+ */
8
+
9
+ import React, { useCallback, useState, useMemo } from 'react';
10
+ import {
11
+ Plus,
12
+ MessageSquare,
13
+ Camera,
14
+ Filter,
15
+ Edit2,
16
+ User,
17
+ Calendar,
18
+ } from 'lucide-react';
19
+ import { Button } from '@/components/ui/button';
20
+ import { Input } from '@/components/ui/input';
21
+ import { Label } from '@/components/ui/label';
22
+ import {
23
+ Select,
24
+ SelectContent,
25
+ SelectItem,
26
+ SelectTrigger,
27
+ SelectValue,
28
+ } from '@/components/ui/select';
29
+ import { ScrollArea } from '@/components/ui/scroll-area';
30
+ import type { BCFTopic } from '@ifc-lite/bcf';
31
+ import { StatusBadge, PriorityBadge, formatDate, TOPIC_STATUSES } from './bcfHelpers';
32
+
33
+ // ============================================================================
34
+ // Types
35
+ // ============================================================================
36
+
37
+ export interface BCFTopicListProps {
38
+ topics: BCFTopic[];
39
+ onSelectTopic: (topicId: string) => void;
40
+ onCreateTopic: () => void;
41
+ statusFilter: string;
42
+ onStatusFilterChange: (status: string) => void;
43
+ author: string;
44
+ onSetAuthor: (author: string) => void;
45
+ }
46
+
47
+ // ============================================================================
48
+ // Component
49
+ // ============================================================================
50
+
51
+ export function BCFTopicList({
52
+ topics,
53
+ onSelectTopic,
54
+ onCreateTopic,
55
+ statusFilter,
56
+ onStatusFilterChange,
57
+ author,
58
+ onSetAuthor,
59
+ }: BCFTopicListProps) {
60
+ const [editingEmail, setEditingEmail] = useState(false);
61
+ const [emailInput, setEmailInput] = useState(author);
62
+ const isDefaultEmail = author === 'user@example.com';
63
+
64
+ const handleSaveEmail = useCallback(() => {
65
+ if (emailInput.trim() && emailInput.includes('@')) {
66
+ onSetAuthor(emailInput.trim());
67
+ setEditingEmail(false);
68
+ }
69
+ }, [emailInput, onSetAuthor]);
70
+ const filteredTopics = useMemo(() => {
71
+ if (!statusFilter || statusFilter === 'all') return topics;
72
+ return topics.filter(
73
+ (t) => t.topicStatus?.toLowerCase() === statusFilter.toLowerCase()
74
+ );
75
+ }, [topics, statusFilter]);
76
+
77
+ // Sort by creation date (newest first)
78
+ const sortedTopics = useMemo(() => {
79
+ return [...filteredTopics].sort(
80
+ (a, b) => new Date(b.creationDate).getTime() - new Date(a.creationDate).getTime()
81
+ );
82
+ }, [filteredTopics]);
83
+
84
+ return (
85
+ <div className="flex flex-col h-full">
86
+ {/* Filter */}
87
+ <div className="flex items-center gap-2 px-3 py-2 border-b border-border">
88
+ <Filter className="h-4 w-4 text-muted-foreground" />
89
+ <Select value={statusFilter} onValueChange={onStatusFilterChange}>
90
+ <SelectTrigger className="h-8 flex-1">
91
+ <SelectValue placeholder="All statuses" />
92
+ </SelectTrigger>
93
+ <SelectContent>
94
+ <SelectItem value="all">All statuses</SelectItem>
95
+ {TOPIC_STATUSES.map((status) => (
96
+ <SelectItem key={status} value={status.toLowerCase()}>
97
+ {status}
98
+ </SelectItem>
99
+ ))}
100
+ </SelectContent>
101
+ </Select>
102
+ <Button size="sm" variant="outline" onClick={onCreateTopic}>
103
+ <Plus className="h-4 w-4" />
104
+ </Button>
105
+ </div>
106
+
107
+ {/* Topic List */}
108
+ <ScrollArea className="flex-1">
109
+ {sortedTopics.length === 0 ? (
110
+ <div className="flex flex-col items-center justify-center py-8 px-4 text-muted-foreground text-sm">
111
+ <MessageSquare className="h-8 w-8 mb-2 opacity-50" />
112
+ <p>No topics</p>
113
+ <Button
114
+ variant="link"
115
+ size="sm"
116
+ onClick={onCreateTopic}
117
+ className="mt-1"
118
+ >
119
+ Create first topic
120
+ </Button>
121
+
122
+ {/* Email setup nudge */}
123
+ <div className="mt-6 w-full max-w-xs">
124
+ <div className="border border-border rounded-lg p-3 bg-muted/30">
125
+ {editingEmail ? (
126
+ <div className="space-y-2">
127
+ <Label className="text-xs text-muted-foreground">Your email for BCF authorship</Label>
128
+ <Input
129
+ value={emailInput}
130
+ onChange={(e) => setEmailInput(e.target.value)}
131
+ placeholder="your@email.com"
132
+ className="h-8 text-sm"
133
+ onKeyDown={(e) => {
134
+ if (e.key === 'Enter') handleSaveEmail();
135
+ if (e.key === 'Escape') setEditingEmail(false);
136
+ }}
137
+ autoFocus
138
+ />
139
+ <div className="flex gap-2 justify-end">
140
+ <Button
141
+ variant="ghost"
142
+ size="sm"
143
+ onClick={() => {
144
+ setEmailInput(author);
145
+ setEditingEmail(false);
146
+ }}
147
+ className="h-7 text-xs"
148
+ >
149
+ Cancel
150
+ </Button>
151
+ <Button
152
+ size="sm"
153
+ onClick={handleSaveEmail}
154
+ disabled={!emailInput.trim() || !emailInput.includes('@')}
155
+ className="h-7 text-xs"
156
+ >
157
+ Save
158
+ </Button>
159
+ </div>
160
+ </div>
161
+ ) : (
162
+ <div className="flex items-center justify-between gap-2">
163
+ <div className="flex-1 min-w-0">
164
+ <p className="text-xs text-muted-foreground mb-0.5">Author</p>
165
+ <p className={`text-sm truncate ${isDefaultEmail ? 'text-amber-600 dark:text-amber-400' : 'text-foreground'}`}>
166
+ {author}
167
+ </p>
168
+ </div>
169
+ <Button
170
+ variant={isDefaultEmail ? 'default' : 'ghost'}
171
+ size="sm"
172
+ onClick={() => {
173
+ setEmailInput(author);
174
+ setEditingEmail(true);
175
+ }}
176
+ className="h-7 text-xs shrink-0"
177
+ >
178
+ {isDefaultEmail ? 'Set email' : <Edit2 className="h-3 w-3" />}
179
+ </Button>
180
+ </div>
181
+ )}
182
+ </div>
183
+ {isDefaultEmail && !editingEmail && (
184
+ <p className="text-xs text-muted-foreground mt-2 text-center">
185
+ Set your email to identify your issues and comments
186
+ </p>
187
+ )}
188
+ </div>
189
+ </div>
190
+ ) : (
191
+ <div className="divide-y divide-border">
192
+ {sortedTopics.map((topic) => (
193
+ <button
194
+ key={topic.guid}
195
+ onClick={() => onSelectTopic(topic.guid)}
196
+ className="w-full text-left p-3 hover:bg-accent/50 transition-colors"
197
+ >
198
+ <div className="flex items-start justify-between gap-2 mb-1">
199
+ <h4 className="font-medium text-sm line-clamp-1 flex-1">
200
+ {topic.title}
201
+ </h4>
202
+ <StatusBadge status={topic.topicStatus} />
203
+ </div>
204
+ {topic.description && (
205
+ <p className="text-xs text-muted-foreground line-clamp-2 mb-2">
206
+ {topic.description}
207
+ </p>
208
+ )}
209
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
210
+ <PriorityBadge priority={topic.priority} />
211
+ <span className="flex items-center gap-1">
212
+ <User className="h-3 w-3" />
213
+ {topic.creationAuthor.split('@')[0]}
214
+ </span>
215
+ <span className="flex items-center gap-1">
216
+ <Calendar className="h-3 w-3" />
217
+ {formatDate(topic.creationDate)}
218
+ </span>
219
+ {topic.comments.length > 0 && (
220
+ <span className="flex items-center gap-1">
221
+ <MessageSquare className="h-3 w-3" />
222
+ {topic.comments.length}
223
+ </span>
224
+ )}
225
+ {topic.viewpoints.length > 0 && (
226
+ <span className="flex items-center gap-1">
227
+ <Camera className="h-3 w-3" />
228
+ {topic.viewpoints.length}
229
+ </span>
230
+ )}
231
+ </div>
232
+ </button>
233
+ ))}
234
+ </div>
235
+ )}
236
+ </ScrollArea>
237
+ </div>
238
+ );
239
+ }
@@ -0,0 +1,109 @@
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
+ * Shared constants, helper components, and utility functions for BCF panel components.
7
+ */
8
+
9
+ import { useMemo } from 'react';
10
+ import { AlertCircle, CheckCircle, Clock } from 'lucide-react';
11
+ import { Badge } from '@/components/ui/badge';
12
+
13
+ // ============================================================================
14
+ // Constants
15
+ // ============================================================================
16
+
17
+ export const TOPIC_TYPES = ['Issue', 'Request', 'Comment', 'Error', 'Warning', 'Info'];
18
+ export const TOPIC_STATUSES = ['Open', 'In Progress', 'Resolved', 'Closed'];
19
+ export const PRIORITIES = ['High', 'Medium', 'Low'];
20
+
21
+ // ============================================================================
22
+ // Helper Components
23
+ // ============================================================================
24
+
25
+ export function StatusBadge({ status }: { status?: string }) {
26
+ const variant = useMemo(() => {
27
+ switch (status?.toLowerCase()) {
28
+ case 'open':
29
+ return 'default';
30
+ case 'in progress':
31
+ return 'secondary';
32
+ case 'resolved':
33
+ case 'closed':
34
+ return 'outline';
35
+ default:
36
+ return 'default';
37
+ }
38
+ }, [status]);
39
+
40
+ const Icon = useMemo(() => {
41
+ switch (status?.toLowerCase()) {
42
+ case 'open':
43
+ return AlertCircle;
44
+ case 'in progress':
45
+ return Clock;
46
+ case 'resolved':
47
+ case 'closed':
48
+ return CheckCircle;
49
+ default:
50
+ return AlertCircle;
51
+ }
52
+ }, [status]);
53
+
54
+ return (
55
+ <Badge variant={variant} className="text-xs gap-1">
56
+ <Icon className="h-3 w-3" />
57
+ {status || 'Open'}
58
+ </Badge>
59
+ );
60
+ }
61
+
62
+ export function PriorityBadge({ priority }: { priority?: string }) {
63
+ const colorClass = useMemo(() => {
64
+ switch (priority?.toLowerCase()) {
65
+ case 'high':
66
+ return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
67
+ case 'medium':
68
+ return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
69
+ case 'low':
70
+ return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
71
+ default:
72
+ return '';
73
+ }
74
+ }, [priority]);
75
+
76
+ if (!priority) return null;
77
+
78
+ return (
79
+ <Badge variant="outline" className={`text-xs ${colorClass}`}>
80
+ {priority}
81
+ </Badge>
82
+ );
83
+ }
84
+
85
+ // ============================================================================
86
+ // Date Formatters
87
+ // ============================================================================
88
+
89
+ export function formatDate(isoDate: string): string {
90
+ const date = new Date(isoDate);
91
+ if (isNaN(date.getTime())) return isoDate;
92
+ return date.toLocaleDateString(undefined, {
93
+ year: 'numeric',
94
+ month: 'short',
95
+ day: 'numeric',
96
+ });
97
+ }
98
+
99
+ export function formatDateTime(isoDate: string): string {
100
+ const date = new Date(isoDate);
101
+ if (isNaN(date.getTime())) return isoDate;
102
+ return date.toLocaleString(undefined, {
103
+ year: 'numeric',
104
+ month: 'short',
105
+ day: 'numeric',
106
+ hour: '2-digit',
107
+ minute: '2-digit',
108
+ });
109
+ }
@@ -0,0 +1,328 @@
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
+ import {
6
+ ChevronRight,
7
+ Building2,
8
+ Layers,
9
+ MapPin,
10
+ FolderKanban,
11
+ Square,
12
+ Box,
13
+ DoorOpen,
14
+ Eye,
15
+ EyeOff,
16
+ FileBox,
17
+ X,
18
+ } from 'lucide-react';
19
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
20
+ import { cn } from '@/lib/utils';
21
+ import type { TreeNode } from './types';
22
+ import { isSpatialContainer } from './types';
23
+
24
+ const TYPE_ICONS: Record<string, React.ElementType> = {
25
+ 'unified-storey': Layers,
26
+ 'model-header': FileBox,
27
+ IfcProject: FolderKanban,
28
+ IfcSite: MapPin,
29
+ IfcBuilding: Building2,
30
+ IfcBuildingStorey: Layers,
31
+ IfcSpace: Box,
32
+ IfcWall: Square,
33
+ IfcWallStandardCase: Square,
34
+ IfcDoor: DoorOpen,
35
+ element: Box,
36
+ default: Box,
37
+ };
38
+
39
+ export interface HierarchyNodeProps {
40
+ node: TreeNode;
41
+ virtualRow: { size: number; start: number };
42
+ isSelected: boolean;
43
+ nodeHidden: boolean;
44
+ isMultiModel: boolean;
45
+ modelsCount: number;
46
+ modelVisible?: boolean;
47
+ onNodeClick: (node: TreeNode, e: React.MouseEvent) => void;
48
+ onToggleExpand: (nodeId: string) => void;
49
+ onVisibilityToggle: (node: TreeNode) => void;
50
+ onModelVisibilityToggle: (modelId: string, e: React.MouseEvent) => void;
51
+ onRemoveModel: (modelId: string, e: React.MouseEvent) => void;
52
+ onModelHeaderClick: (modelId: string, nodeId: string, hasChildren: boolean) => void;
53
+ }
54
+
55
+ export function HierarchyNode({
56
+ node,
57
+ virtualRow,
58
+ isSelected,
59
+ nodeHidden,
60
+ isMultiModel,
61
+ modelsCount,
62
+ modelVisible,
63
+ onNodeClick,
64
+ onToggleExpand,
65
+ onVisibilityToggle,
66
+ onModelVisibilityToggle,
67
+ onRemoveModel,
68
+ onModelHeaderClick,
69
+ }: HierarchyNodeProps) {
70
+ const Icon = TYPE_ICONS[node.type] || TYPE_ICONS.default;
71
+
72
+ // Model header nodes (for visibility control and expansion)
73
+ if (node.type === 'model-header' && node.id.startsWith('model-')) {
74
+ const modelId = node.modelIds[0];
75
+
76
+ return (
77
+ <div
78
+ style={{
79
+ position: 'absolute',
80
+ top: 0,
81
+ left: 0,
82
+ width: '100%',
83
+ height: `${virtualRow.size}px`,
84
+ transform: `translateY(${virtualRow.start}px)`,
85
+ }}
86
+ >
87
+ <div
88
+ className={cn(
89
+ 'flex items-center gap-1 px-2 py-1.5 border-l-4 transition-all group',
90
+ 'hover:bg-zinc-50 dark:hover:bg-zinc-900',
91
+ 'border-transparent',
92
+ !modelVisible && 'opacity-50',
93
+ node.hasChildren && 'cursor-pointer'
94
+ )}
95
+ style={{ paddingLeft: '8px' }}
96
+ onClick={() => onModelHeaderClick(modelId, node.id, node.hasChildren)}
97
+ >
98
+ {/* Expand/collapse chevron */}
99
+ {node.hasChildren ? (
100
+ <ChevronRight
101
+ className={cn(
102
+ 'h-3.5 w-3.5 text-zinc-400 transition-transform shrink-0',
103
+ node.isExpanded && 'rotate-90'
104
+ )}
105
+ />
106
+ ) : (
107
+ <div className="w-3.5" />
108
+ )}
109
+
110
+ <FileBox className="h-3.5 w-3.5 text-primary shrink-0" />
111
+ <span className="flex-1 text-sm truncate ml-1.5 text-zinc-900 dark:text-zinc-100">
112
+ {node.name}
113
+ </span>
114
+
115
+ {node.elementCount !== undefined && (
116
+ <span className="text-[10px] font-mono bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 text-zinc-500 dark:text-zinc-400 rounded-none">
117
+ {node.elementCount.toLocaleString()}
118
+ </span>
119
+ )}
120
+
121
+ <Tooltip>
122
+ <TooltipTrigger asChild>
123
+ <button
124
+ onClick={(e) => {
125
+ e.stopPropagation();
126
+ onModelVisibilityToggle(modelId, e);
127
+ }}
128
+ className="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
129
+ >
130
+ {modelVisible ? (
131
+ <Eye className="h-3.5 w-3.5 text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100" />
132
+ ) : (
133
+ <EyeOff className="h-3.5 w-3.5 text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100" />
134
+ )}
135
+ </button>
136
+ </TooltipTrigger>
137
+ <TooltipContent>
138
+ <p className="text-xs">{modelVisible ? 'Hide model' : 'Show model'}</p>
139
+ </TooltipContent>
140
+ </Tooltip>
141
+
142
+ {modelsCount > 1 && (
143
+ <Tooltip>
144
+ <TooltipTrigger asChild>
145
+ <button
146
+ onClick={(e) => {
147
+ e.stopPropagation();
148
+ onRemoveModel(modelId, e);
149
+ }}
150
+ className="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
151
+ >
152
+ <X className="h-3.5 w-3.5 text-zinc-400 hover:text-red-500" />
153
+ </button>
154
+ </TooltipTrigger>
155
+ <TooltipContent>
156
+ <p className="text-xs">Remove model</p>
157
+ </TooltipContent>
158
+ </Tooltip>
159
+ )}
160
+ </div>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ // Regular node rendering (spatial hierarchy nodes and elements)
166
+ return (
167
+ <div
168
+ style={{
169
+ position: 'absolute',
170
+ top: 0,
171
+ left: 0,
172
+ width: '100%',
173
+ height: `${virtualRow.size}px`,
174
+ transform: `translateY(${virtualRow.start}px)`,
175
+ }}
176
+ >
177
+ <div
178
+ className={cn(
179
+ 'flex items-center gap-1 px-2 py-1.5 border-l-4 transition-all group hierarchy-item',
180
+ // No selection styling for spatial containers in multi-model mode
181
+ isMultiModel && isSpatialContainer(node.type)
182
+ ? 'border-transparent cursor-default'
183
+ : cn(
184
+ 'cursor-pointer',
185
+ isSelected ? 'border-l-primary font-medium selected' : 'border-transparent'
186
+ ),
187
+ nodeHidden && 'opacity-50 grayscale'
188
+ )}
189
+ style={{
190
+ paddingLeft: `${node.depth * 16 + 8}px`,
191
+ // No selection highlighting for spatial containers in multi-model mode
192
+ backgroundColor: isSelected && !(isMultiModel && isSpatialContainer(node.type))
193
+ ? 'var(--hierarchy-selected-bg)' : undefined,
194
+ color: isSelected && !(isMultiModel && isSpatialContainer(node.type))
195
+ ? 'var(--hierarchy-selected-text)' : undefined,
196
+ }}
197
+ onClick={(e) => {
198
+ if ((e.target as HTMLElement).closest('button') === null) {
199
+ onNodeClick(node, e);
200
+ }
201
+ }}
202
+ onMouseDown={(e) => {
203
+ if ((e.target as HTMLElement).closest('button') === null) {
204
+ e.preventDefault();
205
+ }
206
+ }}
207
+ >
208
+ {/* Expand/Collapse */}
209
+ {node.hasChildren ? (
210
+ <button
211
+ onClick={(e) => {
212
+ e.stopPropagation();
213
+ onToggleExpand(node.id);
214
+ }}
215
+ className="p-0.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-none mr-1"
216
+ >
217
+ <ChevronRight
218
+ className={cn(
219
+ 'h-3.5 w-3.5 transition-transform duration-200',
220
+ node.isExpanded && 'rotate-90'
221
+ )}
222
+ />
223
+ </button>
224
+ ) : (
225
+ <div className="w-5" />
226
+ )}
227
+
228
+ {/* Visibility Toggle - hide for spatial containers (Project/Site/Building) in multi-model mode */}
229
+ {!(isMultiModel && isSpatialContainer(node.type)) && (
230
+ <Tooltip>
231
+ <TooltipTrigger asChild>
232
+ <button
233
+ onClick={(e) => {
234
+ e.stopPropagation();
235
+ onVisibilityToggle(node);
236
+ }}
237
+ className={cn(
238
+ 'p-0.5 opacity-0 group-hover:opacity-100 transition-opacity mr-1',
239
+ nodeHidden && 'opacity-100'
240
+ )}
241
+ >
242
+ {node.isVisible ? (
243
+ <Eye className="h-3 w-3 text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100" />
244
+ ) : (
245
+ <EyeOff className="h-3 w-3 text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100" />
246
+ )}
247
+ </button>
248
+ </TooltipTrigger>
249
+ <TooltipContent>
250
+ <p className="text-xs">
251
+ {node.isVisible ? 'Hide' : 'Show'}
252
+ </p>
253
+ </TooltipContent>
254
+ </Tooltip>
255
+ )}
256
+
257
+ {/* Type Icon */}
258
+ <Tooltip>
259
+ <TooltipTrigger asChild>
260
+ <Icon className="h-3.5 w-3.5 shrink-0 text-zinc-500 dark:text-zinc-400" />
261
+ </TooltipTrigger>
262
+ <TooltipContent>
263
+ <p className="text-xs">{node.type}</p>
264
+ </TooltipContent>
265
+ </Tooltip>
266
+
267
+ {/* Name */}
268
+ <span className={cn(
269
+ 'flex-1 text-sm truncate ml-1.5',
270
+ isSpatialContainer(node.type)
271
+ ? 'font-medium text-zinc-900 dark:text-zinc-100'
272
+ : 'text-zinc-700 dark:text-zinc-300',
273
+ nodeHidden && 'line-through decoration-zinc-400 dark:decoration-zinc-600'
274
+ )}>{node.name}</span>
275
+
276
+ {/* Storey Elevation */}
277
+ {node.storeyElevation !== undefined && (
278
+ <Tooltip>
279
+ <TooltipTrigger asChild>
280
+ <span className="text-[10px] font-mono bg-emerald-100 dark:bg-emerald-950 px-1.5 py-0.5 border border-emerald-200 dark:border-emerald-800 text-emerald-600 dark:text-emerald-400 rounded-none">
281
+ {node.storeyElevation >= 0 ? '+' : ''}{node.storeyElevation.toFixed(2)}m
282
+ </span>
283
+ </TooltipTrigger>
284
+ <TooltipContent>
285
+ <p className="text-xs">Elevation: {node.storeyElevation >= 0 ? '+' : ''}{node.storeyElevation.toFixed(2)}m</p>
286
+ </TooltipContent>
287
+ </Tooltip>
288
+ )}
289
+
290
+ {/* Element Count */}
291
+ {node.elementCount !== undefined && (
292
+ <Tooltip>
293
+ <TooltipTrigger asChild>
294
+ <span className="text-[10px] font-mono bg-zinc-100 dark:bg-zinc-950 px-1.5 py-0.5 border border-zinc-200 dark:border-zinc-800 text-zinc-500 dark:text-zinc-400 rounded-none">
295
+ {node.elementCount}
296
+ </span>
297
+ </TooltipTrigger>
298
+ <TooltipContent>
299
+ <p className="text-xs">{node.elementCount} {node.elementCount === 1 ? 'element' : 'elements'}</p>
300
+ </TooltipContent>
301
+ </Tooltip>
302
+ )}
303
+ </div>
304
+ </div>
305
+ );
306
+ }
307
+
308
+ export interface SectionHeaderProps {
309
+ icon: React.ElementType;
310
+ title: string;
311
+ count?: number;
312
+ }
313
+
314
+ export function SectionHeader({ icon: IconComponent, title, count }: SectionHeaderProps) {
315
+ return (
316
+ <div className="flex items-center gap-2 px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800">
317
+ <IconComponent className="h-3.5 w-3.5 text-zinc-500" />
318
+ <span className="text-[10px] font-bold uppercase tracking-wider text-zinc-600 dark:text-zinc-400">
319
+ {title}
320
+ </span>
321
+ {count !== undefined && (
322
+ <span className="text-[10px] font-mono text-zinc-400 dark:text-zinc-500 ml-auto">
323
+ {count}
324
+ </span>
325
+ )}
326
+ </div>
327
+ );
328
+ }