@ifc-lite/viewer 1.1.7 → 1.5.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 (164) hide show
  1. package/LICENSE +373 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/Arrow.dom-B0e15b_b.js +20 -0
  4. package/dist/assets/arrow2-bb-jcVEo.js +2 -0
  5. package/dist/assets/arrow2_bg-4Y7xYo54.wasm +0 -0
  6. package/dist/assets/arrow2_bg-BlXl-cSQ.js +1 -0
  7. package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
  8. package/dist/assets/desktop-cache-oPzaWXYE.js +1 -0
  9. package/dist/assets/event-DIOks52T.js +1 -0
  10. package/dist/assets/ifc-cache-BAN4vcd4.js +1 -0
  11. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  12. package/dist/assets/index-Dgd6vzw_.js +65252 -0
  13. package/dist/assets/index-v3mcCUPN.css +1 -0
  14. package/dist/assets/native-bridge-Ci7NLjlZ.js +111 -0
  15. package/dist/assets/wasm-bridge-Dc82YpdZ.js +1 -0
  16. package/dist/favicon-16x16-cropped.png +0 -0
  17. package/dist/favicon-16x16.png +0 -0
  18. package/dist/favicon-192x192-cropped.png +0 -0
  19. package/dist/favicon-192x192.png +0 -0
  20. package/dist/favicon-32x32-cropped.png +0 -0
  21. package/dist/favicon-32x32.png +0 -0
  22. package/dist/favicon-48x48-cropped.png +0 -0
  23. package/dist/favicon-48x48.png +0 -0
  24. package/dist/favicon-512x512-cropped.png +0 -0
  25. package/dist/favicon-512x512.png +0 -0
  26. package/dist/favicon-64x64-cropped.png +0 -0
  27. package/dist/favicon-64x64.png +0 -0
  28. package/dist/favicon-96x96-cropped.png +0 -0
  29. package/dist/favicon-96x96.png +0 -0
  30. package/dist/favicon-square-512.png +0 -0
  31. package/dist/favicon.ico +0 -0
  32. package/dist/favicon.png +0 -0
  33. package/dist/favicon.svg +3 -0
  34. package/dist/index.html +44 -0
  35. package/dist/logo.png +0 -0
  36. package/dist/manifest.json +48 -0
  37. package/index.html +33 -2
  38. package/package.json +34 -17
  39. package/public/apple-touch-icon.png +0 -0
  40. package/public/favicon-16x16-cropped.png +0 -0
  41. package/public/favicon-16x16.png +0 -0
  42. package/public/favicon-192x192-cropped.png +0 -0
  43. package/public/favicon-192x192.png +0 -0
  44. package/public/favicon-32x32-cropped.png +0 -0
  45. package/public/favicon-32x32.png +0 -0
  46. package/public/favicon-48x48-cropped.png +0 -0
  47. package/public/favicon-48x48.png +0 -0
  48. package/public/favicon-512x512-cropped.png +0 -0
  49. package/public/favicon-512x512.png +0 -0
  50. package/public/favicon-64x64-cropped.png +0 -0
  51. package/public/favicon-64x64.png +0 -0
  52. package/public/favicon-96x96-cropped.png +0 -0
  53. package/public/favicon-96x96.png +0 -0
  54. package/public/favicon-square-512.png +0 -0
  55. package/public/favicon.ico +0 -0
  56. package/public/favicon.png +0 -0
  57. package/public/favicon.svg +3 -0
  58. package/public/logo.png +0 -0
  59. package/public/manifest.json +48 -0
  60. package/src/App.tsx +2 -0
  61. package/src/components/ui/alert.tsx +62 -0
  62. package/src/components/ui/badge.tsx +39 -0
  63. package/src/components/ui/dialog.tsx +120 -0
  64. package/src/components/ui/label.tsx +27 -0
  65. package/src/components/ui/select.tsx +151 -0
  66. package/src/components/ui/switch.tsx +30 -0
  67. package/src/components/ui/table.tsx +120 -0
  68. package/src/components/ui/tabs.tsx +1 -1
  69. package/src/components/viewer/BCFPanel.tsx +1164 -0
  70. package/src/components/viewer/BulkPropertyEditor.tsx +875 -0
  71. package/src/components/viewer/DataConnector.tsx +840 -0
  72. package/src/components/viewer/DrawingSettingsPanel.tsx +536 -0
  73. package/src/components/viewer/EntityContextMenu.tsx +45 -17
  74. package/src/components/viewer/ExportChangesButton.tsx +195 -0
  75. package/src/components/viewer/ExportDialog.tsx +402 -0
  76. package/src/components/viewer/HierarchyPanel.tsx +1132 -218
  77. package/src/components/viewer/IDSPanel.tsx +661 -0
  78. package/src/components/viewer/KeyboardShortcutsDialog.tsx +245 -39
  79. package/src/components/viewer/MainToolbar.tsx +418 -94
  80. package/src/components/viewer/PropertiesPanel.tsx +1355 -91
  81. package/src/components/viewer/PropertyEditor.tsx +611 -0
  82. package/src/components/viewer/Section2DPanel.tsx +3313 -0
  83. package/src/components/viewer/SheetSetupPanel.tsx +502 -0
  84. package/src/components/viewer/StatusBar.tsx +27 -16
  85. package/src/components/viewer/TitleBlockEditor.tsx +437 -0
  86. package/src/components/viewer/ToolOverlays.tsx +935 -127
  87. package/src/components/viewer/ViewerLayout.tsx +40 -11
  88. package/src/components/viewer/Viewport.tsx +1276 -336
  89. package/src/components/viewer/ViewportContainer.tsx +554 -18
  90. package/src/components/viewer/ViewportOverlays.tsx +24 -7
  91. package/src/hooks/useBCF.ts +504 -0
  92. package/src/hooks/useIDS.ts +1065 -0
  93. package/src/hooks/useIfc.ts +1534 -205
  94. package/src/hooks/useIfcCache.ts +279 -0
  95. package/src/hooks/useKeyboardShortcuts.ts +50 -8
  96. package/src/hooks/useModelSelection.ts +61 -0
  97. package/src/hooks/useViewerSelectors.ts +218 -0
  98. package/src/hooks/useWebGPU.ts +80 -0
  99. package/src/index.css +265 -27
  100. package/src/lib/platform.ts +23 -0
  101. package/src/services/cacheService.ts +142 -0
  102. package/src/services/desktop-cache.ts +143 -0
  103. package/src/services/fs-cache.ts +212 -0
  104. package/src/services/ifc-cache.ts +14 -6
  105. package/src/store/constants.ts +85 -0
  106. package/src/store/index.ts +214 -0
  107. package/src/store/slices/bcfSlice.ts +372 -0
  108. package/src/store/slices/cameraSlice.ts +63 -0
  109. package/src/store/slices/dataSlice.test.ts +226 -0
  110. package/src/store/slices/dataSlice.ts +112 -0
  111. package/src/store/slices/drawing2DSlice.ts +340 -0
  112. package/src/store/slices/hoverSlice.ts +40 -0
  113. package/src/store/slices/idsSlice.ts +310 -0
  114. package/src/store/slices/loadingSlice.ts +33 -0
  115. package/src/store/slices/measurementSlice.test.ts +217 -0
  116. package/src/store/slices/measurementSlice.ts +293 -0
  117. package/src/store/slices/modelSlice.test.ts +271 -0
  118. package/src/store/slices/modelSlice.ts +211 -0
  119. package/src/store/slices/mutationSlice.ts +502 -0
  120. package/src/store/slices/sectionSlice.test.ts +125 -0
  121. package/src/store/slices/sectionSlice.ts +58 -0
  122. package/src/store/slices/selectionSlice.test.ts +286 -0
  123. package/src/store/slices/selectionSlice.ts +263 -0
  124. package/src/store/slices/sheetSlice.ts +565 -0
  125. package/src/store/slices/uiSlice.ts +58 -0
  126. package/src/store/slices/visibilitySlice.test.ts +304 -0
  127. package/src/store/slices/visibilitySlice.ts +277 -0
  128. package/src/store/types.test.ts +135 -0
  129. package/src/store/types.ts +248 -0
  130. package/src/store.ts +40 -515
  131. package/src/utils/ifcConfig.ts +82 -0
  132. package/src/utils/localParsingUtils.ts +287 -0
  133. package/src/utils/serverDataModel.ts +783 -0
  134. package/src/utils/spatialHierarchy.ts +283 -0
  135. package/src/utils/viewportUtils.ts +334 -0
  136. package/src/vite-env.d.ts +23 -0
  137. package/src/webgpu-types.d.ts +128 -0
  138. package/src-tauri/Cargo.toml +29 -0
  139. package/src-tauri/build.rs +7 -0
  140. package/src-tauri/capabilities/default.json +18 -0
  141. package/src-tauri/icons/128x128.png +0 -0
  142. package/src-tauri/icons/128x128@2x.png +0 -0
  143. package/src-tauri/icons/32x32.png +0 -0
  144. package/src-tauri/icons/Square107x107Logo.png +0 -0
  145. package/src-tauri/icons/Square142x142Logo.png +0 -0
  146. package/src-tauri/icons/Square150x150Logo.png +0 -0
  147. package/src-tauri/icons/Square284x284Logo.png +0 -0
  148. package/src-tauri/icons/Square30x30Logo.png +0 -0
  149. package/src-tauri/icons/Square310x310Logo.png +0 -0
  150. package/src-tauri/icons/Square44x44Logo.png +0 -0
  151. package/src-tauri/icons/Square71x71Logo.png +0 -0
  152. package/src-tauri/icons/Square89x89Logo.png +0 -0
  153. package/src-tauri/icons/StoreLogo.png +0 -0
  154. package/src-tauri/icons/icon.icns +0 -0
  155. package/src-tauri/icons/icon.ico +0 -0
  156. package/src-tauri/icons/icon.png +0 -0
  157. package/src-tauri/src/lib.rs +21 -0
  158. package/src-tauri/src/main.rs +10 -0
  159. package/src-tauri/tauri.conf.json +39 -0
  160. package/vite.config.ts +174 -26
  161. package/public/ifc-lite_bg.wasm +0 -0
  162. package/public/web-ifc.wasm +0 -0
  163. package/src/components/Viewport.tsx +0 -723
  164. package/src/components/viewer/BoxSelectionOverlay.tsx +0 -53
@@ -0,0 +1,536 @@
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
+ * DrawingSettingsPanel - Full control over 2D drawing graphic styles
7
+ *
8
+ * Provides:
9
+ * - Preset selection dropdown
10
+ * - Custom rule editor
11
+ * - Color, line weight, hatch controls
12
+ */
13
+
14
+ import React, { useCallback, useState, useMemo } from 'react';
15
+ import { X, Palette, Plus, Trash2, ChevronDown, ChevronRight, GripVertical, Eye, EyeOff, Check, Copy, PenTool, Flame, Building2, Wrench, Printer, type LucideIcon } from 'lucide-react';
16
+ import { Button } from '@/components/ui/button';
17
+ import { Input } from '@/components/ui/input';
18
+ import { Label } from '@/components/ui/label';
19
+ import {
20
+ Select,
21
+ SelectContent,
22
+ SelectItem,
23
+ SelectTrigger,
24
+ SelectValue,
25
+ } from '@/components/ui/select';
26
+ import {
27
+ Collapsible,
28
+ CollapsibleContent,
29
+ CollapsibleTrigger,
30
+ } from '@/components/ui/collapsible';
31
+ import { useViewerStore } from '@/store';
32
+ import type { GraphicOverrideRule, GraphicStyle } from '@ifc-lite/drawing-2d';
33
+
34
+ // Common IFC types for the dropdown
35
+ const COMMON_IFC_TYPES = [
36
+ 'IfcWall',
37
+ 'IfcWallStandardCase',
38
+ 'IfcSlab',
39
+ 'IfcColumn',
40
+ 'IfcBeam',
41
+ 'IfcDoor',
42
+ 'IfcWindow',
43
+ 'IfcStair',
44
+ 'IfcRoof',
45
+ 'IfcRailing',
46
+ 'IfcCovering',
47
+ 'IfcFurnishingElement',
48
+ 'IfcSpace',
49
+ 'IfcBuildingElementProxy',
50
+ ];
51
+
52
+ // Line weight presets
53
+ const LINE_WEIGHTS = [
54
+ { value: 'heavy', label: 'Heavy (0.5mm)' },
55
+ { value: 'medium', label: 'Medium (0.35mm)' },
56
+ { value: 'light', label: 'Light (0.25mm)' },
57
+ { value: 'hairline', label: 'Hairline (0.18mm)' },
58
+ ];
59
+
60
+ // Icon mapping for presets
61
+ const PRESET_ICONS: Record<string, LucideIcon> = {
62
+ Palette,
63
+ PenTool,
64
+ Flame,
65
+ Building2,
66
+ Wrench,
67
+ Printer,
68
+ };
69
+
70
+ function PresetIcon({ iconName, className }: { iconName?: string; className?: string }) {
71
+ const Icon = iconName ? PRESET_ICONS[iconName] : Palette;
72
+ if (!Icon) return <Palette className={className} />;
73
+ return <Icon className={className} />;
74
+ }
75
+
76
+ interface DrawingSettingsPanelProps {
77
+ onClose: () => void;
78
+ }
79
+
80
+ export function DrawingSettingsPanel({ onClose }: DrawingSettingsPanelProps) {
81
+ const graphicOverridePresets = useViewerStore((s) => s.graphicOverridePresets);
82
+ const activePresetId = useViewerStore((s) => s.activePresetId);
83
+ const setActivePreset = useViewerStore((s) => s.setActivePreset);
84
+ const customOverrideRules = useViewerStore((s) => s.customOverrideRules);
85
+ const addCustomRule = useViewerStore((s) => s.addCustomRule);
86
+ const updateCustomRule = useViewerStore((s) => s.updateCustomRule);
87
+ const removeCustomRule = useViewerStore((s) => s.removeCustomRule);
88
+ const overridesEnabled = useViewerStore((s) => s.overridesEnabled);
89
+ const toggleOverridesEnabled = useViewerStore((s) => s.toggleOverridesEnabled);
90
+
91
+ // Expanded sections
92
+ const [presetsOpen, setPresetsOpen] = useState(true);
93
+ const [customRulesOpen, setCustomRulesOpen] = useState(true);
94
+ const [editingRuleId, setEditingRuleId] = useState<string | null>(null);
95
+
96
+ // Get the active preset's rules for display
97
+ const activePreset = useMemo(() => {
98
+ if (!activePresetId) return null;
99
+ return graphicOverridePresets.find((p) => p.id === activePresetId) ?? null;
100
+ }, [activePresetId, graphicOverridePresets]);
101
+
102
+ // Add new custom rule
103
+ const handleAddRule = useCallback(() => {
104
+ const newRule: GraphicOverrideRule = {
105
+ id: `custom-${Date.now()}`,
106
+ name: 'New Rule',
107
+ enabled: true,
108
+ priority: customOverrideRules.length + 100, // Start after presets
109
+ criteria: {
110
+ type: 'ifcType',
111
+ ifcTypes: ['IfcWall'],
112
+ includeSubtypes: true,
113
+ },
114
+ style: {
115
+ fillColor: '#808080',
116
+ strokeColor: '#000000',
117
+ },
118
+ };
119
+ addCustomRule(newRule);
120
+ setEditingRuleId(newRule.id);
121
+ }, [customOverrideRules.length, addCustomRule]);
122
+
123
+ // Copy preset rules to custom rules for editing
124
+ const handleCopyPresetToCustom = useCallback(() => {
125
+ if (!activePreset) return;
126
+
127
+ // Copy each rule from preset to custom with new IDs
128
+ for (const rule of activePreset.rules) {
129
+ const newRule: GraphicOverrideRule = {
130
+ ...rule,
131
+ id: `custom-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
132
+ priority: customOverrideRules.length + 100,
133
+ };
134
+ addCustomRule(newRule);
135
+ }
136
+
137
+ // Clear preset selection and expand custom rules
138
+ setActivePreset(null);
139
+ setCustomRulesOpen(true);
140
+ }, [activePreset, customOverrideRules.length, addCustomRule, setActivePreset]);
141
+
142
+ return (
143
+ <div className="flex flex-col h-full bg-background border-l">
144
+ {/* Header */}
145
+ <div className="flex items-center justify-between px-4 py-3 border-b bg-muted/50">
146
+ <div className="flex items-center gap-2">
147
+ <Palette className="h-5 w-5 text-primary" />
148
+ <h2 className="font-semibold text-sm">Drawing Settings</h2>
149
+ </div>
150
+ <div className="flex items-center gap-2">
151
+ <Button
152
+ variant={overridesEnabled ? 'default' : 'outline'}
153
+ size="sm"
154
+ onClick={toggleOverridesEnabled}
155
+ className="h-7 text-xs"
156
+ >
157
+ {overridesEnabled ? 'Enabled' : 'Disabled'}
158
+ </Button>
159
+ <Button variant="ghost" size="icon-sm" onClick={onClose}>
160
+ <X className="h-4 w-4" />
161
+ </Button>
162
+ </div>
163
+ </div>
164
+
165
+ {/* Content */}
166
+ <div className="flex-1 overflow-y-auto">
167
+ {/* Presets Section */}
168
+ <Collapsible open={presetsOpen} onOpenChange={setPresetsOpen}>
169
+ <CollapsibleTrigger asChild>
170
+ <button className="w-full flex items-center justify-between px-4 py-2 hover:bg-muted/50 transition-colors">
171
+ <span className="text-sm font-medium">Style Presets</span>
172
+ {presetsOpen ? (
173
+ <ChevronDown className="h-4 w-4 text-muted-foreground" />
174
+ ) : (
175
+ <ChevronRight className="h-4 w-4 text-muted-foreground" />
176
+ )}
177
+ </button>
178
+ </CollapsibleTrigger>
179
+ <CollapsibleContent>
180
+ <div className="px-4 pb-3 space-y-1">
181
+ {/* Built-in presets */}
182
+ {graphicOverridePresets.map((preset) => (
183
+ <button
184
+ key={preset.id}
185
+ className={`w-full flex items-center gap-2 px-2 py-1.5 rounded transition-colors text-sm ${
186
+ activePresetId === preset.id
187
+ ? 'bg-primary/10 text-primary'
188
+ : 'hover:bg-muted text-foreground'
189
+ }`}
190
+ onClick={() => setActivePreset(preset.id)}
191
+ >
192
+ <div className={`w-6 h-6 rounded flex items-center justify-center ${activePresetId === preset.id ? 'bg-primary/20' : 'bg-muted'}`}>
193
+ <PresetIcon iconName={preset.icon} className="h-3.5 w-3.5" />
194
+ </div>
195
+ <span className="flex-1 text-left font-medium">{preset.name}</span>
196
+ {activePresetId === preset.id && <Check className="h-3.5 w-3.5" />}
197
+ </button>
198
+ ))}
199
+ </div>
200
+ </CollapsibleContent>
201
+ </Collapsible>
202
+
203
+ {/* Active Preset Rules (read-only with edit option) */}
204
+ {activePreset && activePreset.rules.length > 0 && (
205
+ <div className="border-t">
206
+ <div className="px-4 py-2 flex items-center justify-between">
207
+ <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
208
+ {activePreset.name} Rules
209
+ </h3>
210
+ <Button
211
+ variant="ghost"
212
+ size="sm"
213
+ className="h-6 text-xs"
214
+ onClick={handleCopyPresetToCustom}
215
+ title="Copy rules to custom for editing"
216
+ >
217
+ <Copy className="h-3 w-3 mr-1" />
218
+ Edit as Custom
219
+ </Button>
220
+ </div>
221
+ <div className="px-4 pb-4 space-y-1">
222
+ {activePreset.rules.map((rule) => (
223
+ <PresetRuleItem key={rule.id} rule={rule} />
224
+ ))}
225
+ </div>
226
+ </div>
227
+ )}
228
+
229
+ {/* Custom Rules Section */}
230
+ <Collapsible open={customRulesOpen} onOpenChange={setCustomRulesOpen}>
231
+ <div className="border-t">
232
+ <CollapsibleTrigger asChild>
233
+ <button className="w-full flex items-center justify-between px-4 py-2 hover:bg-muted/50 transition-colors">
234
+ <span className="text-sm font-medium">Custom Rules</span>
235
+ <div className="flex items-center gap-2">
236
+ <span className="text-xs text-muted-foreground">
237
+ {customOverrideRules.length} rules
238
+ </span>
239
+ {customRulesOpen ? (
240
+ <ChevronDown className="h-4 w-4 text-muted-foreground" />
241
+ ) : (
242
+ <ChevronRight className="h-4 w-4 text-muted-foreground" />
243
+ )}
244
+ </div>
245
+ </button>
246
+ </CollapsibleTrigger>
247
+ <CollapsibleContent>
248
+ <div className="px-4 pb-4 space-y-2">
249
+ {customOverrideRules.length === 0 ? (
250
+ <div className="text-center py-4 text-muted-foreground text-sm">
251
+ No custom rules yet
252
+ </div>
253
+ ) : (
254
+ customOverrideRules.map((rule) => (
255
+ <CustomRuleItem
256
+ key={rule.id}
257
+ rule={rule}
258
+ isEditing={editingRuleId === rule.id}
259
+ onEdit={() => setEditingRuleId(rule.id)}
260
+ onSave={() => setEditingRuleId(null)}
261
+ onUpdate={(updates) => updateCustomRule(rule.id, updates)}
262
+ onRemove={() => removeCustomRule(rule.id)}
263
+ />
264
+ ))
265
+ )}
266
+
267
+ <Button
268
+ variant="outline"
269
+ size="sm"
270
+ className="w-full"
271
+ onClick={handleAddRule}
272
+ >
273
+ <Plus className="h-4 w-4 mr-2" />
274
+ Add Custom Rule
275
+ </Button>
276
+ </div>
277
+ </CollapsibleContent>
278
+ </div>
279
+ </Collapsible>
280
+ </div>
281
+ </div>
282
+ );
283
+ }
284
+
285
+ // Read-only preset rule display
286
+ function PresetRuleItem({ rule }: { rule: GraphicOverrideRule }) {
287
+ // Extract IFC types from criteria
288
+ const ifcTypes = useMemo(() => {
289
+ if ('ifcTypes' in rule.criteria && rule.criteria.ifcTypes) {
290
+ return rule.criteria.ifcTypes.join(', ');
291
+ }
292
+ if ('conditions' in rule.criteria) {
293
+ // Find ifcType criteria in conditions
294
+ for (const condition of rule.criteria.conditions) {
295
+ if ('ifcTypes' in condition && condition.ifcTypes) {
296
+ return condition.ifcTypes.join(', ');
297
+ }
298
+ }
299
+ }
300
+ return 'All';
301
+ }, [rule.criteria]);
302
+
303
+ return (
304
+ <div className="flex items-center gap-2 px-2 py-1.5 bg-muted/30 rounded text-xs">
305
+ {rule.style.fillColor && (
306
+ <div
307
+ className="w-4 h-4 rounded border border-black/20"
308
+ style={{ backgroundColor: rule.style.fillColor }}
309
+ />
310
+ )}
311
+ <div className="flex-1 min-w-0">
312
+ <div className="font-medium truncate">{rule.name}</div>
313
+ <div className="text-muted-foreground truncate">{ifcTypes}</div>
314
+ </div>
315
+ </div>
316
+ );
317
+ }
318
+
319
+ // Editable custom rule
320
+ interface CustomRuleItemProps {
321
+ rule: GraphicOverrideRule;
322
+ isEditing: boolean;
323
+ onEdit: () => void;
324
+ onSave: () => void;
325
+ onUpdate: (updates: Partial<GraphicOverrideRule>) => void;
326
+ onRemove: () => void;
327
+ }
328
+
329
+ function CustomRuleItem({
330
+ rule,
331
+ isEditing,
332
+ onEdit,
333
+ onSave,
334
+ onUpdate,
335
+ onRemove,
336
+ }: CustomRuleItemProps) {
337
+ // Extract IFC types
338
+ const ifcTypes = useMemo(() => {
339
+ if ('ifcTypes' in rule.criteria && rule.criteria.ifcTypes) {
340
+ return rule.criteria.ifcTypes;
341
+ }
342
+ return [];
343
+ }, [rule.criteria]);
344
+
345
+ const handleIfcTypeChange = useCallback(
346
+ (type: string) => {
347
+ onUpdate({
348
+ criteria: {
349
+ type: 'ifcType',
350
+ ifcTypes: [type],
351
+ includeSubtypes: true,
352
+ },
353
+ });
354
+ },
355
+ [onUpdate]
356
+ );
357
+
358
+ const handleStyleChange = useCallback(
359
+ (key: keyof GraphicStyle, value: string | number | undefined) => {
360
+ onUpdate({
361
+ style: {
362
+ ...rule.style,
363
+ [key]: value,
364
+ },
365
+ });
366
+ },
367
+ [rule.style, onUpdate]
368
+ );
369
+
370
+ if (!isEditing) {
371
+ return (
372
+ <div
373
+ className="flex items-center gap-2 px-2 py-1.5 bg-muted/30 rounded text-xs cursor-pointer hover:bg-muted/50"
374
+ onClick={onEdit}
375
+ >
376
+ <GripVertical className="h-3 w-3 text-muted-foreground" />
377
+ {rule.style.fillColor && (
378
+ <div
379
+ className="w-4 h-4 rounded border border-black/20"
380
+ style={{ backgroundColor: rule.style.fillColor }}
381
+ />
382
+ )}
383
+ <div className="flex-1 min-w-0">
384
+ <div className="font-medium truncate">{rule.name}</div>
385
+ <div className="text-muted-foreground truncate">
386
+ {ifcTypes.join(', ') || 'Click to edit'}
387
+ </div>
388
+ </div>
389
+ <Button
390
+ variant="ghost"
391
+ size="icon-sm"
392
+ className="h-6 w-6 opacity-0 group-hover:opacity-100"
393
+ onClick={(e) => {
394
+ e.stopPropagation();
395
+ onUpdate({ enabled: !rule.enabled });
396
+ }}
397
+ >
398
+ {rule.enabled ? (
399
+ <Eye className="h-3 w-3" />
400
+ ) : (
401
+ <EyeOff className="h-3 w-3 text-muted-foreground" />
402
+ )}
403
+ </Button>
404
+ </div>
405
+ );
406
+ }
407
+
408
+ return (
409
+ <div className="p-3 bg-muted/30 rounded-lg border space-y-3">
410
+ {/* Name */}
411
+ <div>
412
+ <Label className="text-xs">Rule Name</Label>
413
+ <Input
414
+ value={rule.name}
415
+ onChange={(e) => onUpdate({ name: e.target.value })}
416
+ className="h-8 text-sm mt-1"
417
+ />
418
+ </div>
419
+
420
+ {/* IFC Type */}
421
+ <div>
422
+ <Label className="text-xs">IFC Type</Label>
423
+ <Select
424
+ value={ifcTypes[0] || ''}
425
+ onValueChange={handleIfcTypeChange}
426
+ >
427
+ <SelectTrigger className="h-8 text-sm mt-1">
428
+ <SelectValue placeholder="Select type..." />
429
+ </SelectTrigger>
430
+ <SelectContent>
431
+ {COMMON_IFC_TYPES.map((type) => (
432
+ <SelectItem key={type} value={type}>
433
+ {type}
434
+ </SelectItem>
435
+ ))}
436
+ </SelectContent>
437
+ </Select>
438
+ </div>
439
+
440
+ {/* Colors */}
441
+ <div className="grid grid-cols-2 gap-2">
442
+ <div>
443
+ <Label className="text-xs">Fill Color</Label>
444
+ <div className="flex gap-1 mt-1">
445
+ <input
446
+ type="color"
447
+ value={rule.style.fillColor || '#808080'}
448
+ onChange={(e) => handleStyleChange('fillColor', e.target.value)}
449
+ className="w-8 h-8 rounded border cursor-pointer"
450
+ />
451
+ <Input
452
+ value={rule.style.fillColor || '#808080'}
453
+ onChange={(e) => handleStyleChange('fillColor', e.target.value)}
454
+ className="h-8 text-xs font-mono flex-1"
455
+ />
456
+ </div>
457
+ </div>
458
+ <div>
459
+ <Label className="text-xs">Stroke Color</Label>
460
+ <div className="flex gap-1 mt-1">
461
+ <input
462
+ type="color"
463
+ value={rule.style.strokeColor || '#000000'}
464
+ onChange={(e) => handleStyleChange('strokeColor', e.target.value)}
465
+ className="w-8 h-8 rounded border cursor-pointer"
466
+ />
467
+ <Input
468
+ value={rule.style.strokeColor || '#000000'}
469
+ onChange={(e) => handleStyleChange('strokeColor', e.target.value)}
470
+ className="h-8 text-xs font-mono flex-1"
471
+ />
472
+ </div>
473
+ </div>
474
+ </div>
475
+
476
+ {/* Line Weight - preset or custom mm value */}
477
+ <div>
478
+ <Label className="text-xs">Line Weight</Label>
479
+ <div className="flex gap-2 mt-1">
480
+ <Select
481
+ value={typeof rule.style.lineWeight === 'string' ? rule.style.lineWeight : 'custom'}
482
+ onValueChange={(v) => {
483
+ if (v === 'custom') {
484
+ handleStyleChange('lineWeight', 0.35); // Default to 0.35mm
485
+ } else {
486
+ handleStyleChange('lineWeight', v);
487
+ }
488
+ }}
489
+ >
490
+ <SelectTrigger className="h-8 text-sm flex-1">
491
+ <SelectValue />
492
+ </SelectTrigger>
493
+ <SelectContent>
494
+ {LINE_WEIGHTS.map((w) => (
495
+ <SelectItem key={w.value} value={w.value}>
496
+ {w.label}
497
+ </SelectItem>
498
+ ))}
499
+ <SelectItem value="custom">Custom...</SelectItem>
500
+ </SelectContent>
501
+ </Select>
502
+ {typeof rule.style.lineWeight === 'number' && (
503
+ <div className="flex items-center gap-1">
504
+ <Input
505
+ type="number"
506
+ min={0.05}
507
+ max={2}
508
+ step={0.05}
509
+ value={rule.style.lineWeight}
510
+ onChange={(e) => handleStyleChange('lineWeight', parseFloat(e.target.value) || 0.35)}
511
+ className="h-8 w-16 text-xs"
512
+ />
513
+ <span className="text-xs text-muted-foreground">mm</span>
514
+ </div>
515
+ )}
516
+ </div>
517
+ </div>
518
+
519
+ {/* Actions */}
520
+ <div className="flex items-center justify-between pt-2 border-t">
521
+ <Button
522
+ variant="ghost"
523
+ size="sm"
524
+ className="text-destructive hover:text-destructive"
525
+ onClick={onRemove}
526
+ >
527
+ <Trash2 className="h-4 w-4 mr-1" />
528
+ Delete
529
+ </Button>
530
+ <Button size="sm" onClick={onSave}>
531
+ Done
532
+ </Button>
533
+ </div>
534
+ </div>
535
+ );
536
+ }
@@ -6,7 +6,7 @@
6
6
  * Context menu for entity interactions
7
7
  */
8
8
 
9
- import { useCallback, useEffect, useRef } from 'react';
9
+ import { useCallback, useEffect, useRef, useMemo } from 'react';
10
10
  import {
11
11
  Focus,
12
12
  EyeOff,
@@ -28,8 +28,30 @@ export function EntityContextMenu() {
28
28
  const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
29
29
  const setSelectedEntityIds = useViewerStore((s) => s.setSelectedEntityIds);
30
30
  const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks);
31
+ const resolveGlobalIdFromModels = useViewerStore((s) => s.resolveGlobalIdFromModels);
31
32
  const menuRef = useRef<HTMLDivElement>(null);
32
- const { ifcDataStore } = useIfc();
33
+ const { ifcDataStore, models } = useIfc();
34
+
35
+ // Resolve contextMenu.entityId (globalId) to original expressId and model
36
+ // This is needed because IfcDataStore uses original expressIds, not globalIds
37
+ const { resolvedExpressId, activeDataStore } = useMemo(() => {
38
+ if (!contextMenu.entityId) {
39
+ return { resolvedExpressId: null, activeDataStore: ifcDataStore };
40
+ }
41
+
42
+ // Use store-based resolver (more reliable than singleton)
43
+ const resolved = resolveGlobalIdFromModels(contextMenu.entityId);
44
+ if (resolved) {
45
+ const model = models.get(resolved.modelId);
46
+ return {
47
+ resolvedExpressId: resolved.expressId,
48
+ activeDataStore: model?.ifcDataStore ?? ifcDataStore,
49
+ };
50
+ }
51
+
52
+ // Fallback for single-model mode (offset = 0)
53
+ return { resolvedExpressId: contextMenu.entityId, activeDataStore: ifcDataStore };
54
+ }, [contextMenu.entityId, models, ifcDataStore, resolveGlobalIdFromModels]);
33
55
 
34
56
  // Close menu when clicking outside
35
57
  useEffect(() => {
@@ -87,24 +109,26 @@ export function EntityContextMenu() {
87
109
  }, [showAll, closeContextMenu]);
88
110
 
89
111
  const handleSelectSimilar = useCallback(() => {
90
- if (!contextMenu.entityId || !ifcDataStore) {
112
+ // Use resolvedExpressId (original ID) for IfcDataStore lookups
113
+ if (!resolvedExpressId || !activeDataStore) {
91
114
  closeContextMenu();
92
115
  return;
93
116
  }
94
117
 
95
118
  // Get the type of the selected entity
96
- const entity = ifcDataStore.entities;
119
+ const entity = activeDataStore.entities;
97
120
  let entityType: string | null = null;
98
121
 
99
122
  for (let i = 0; i < entity.count; i++) {
100
- if (entity.expressId[i] === contextMenu.entityId) {
101
- entityType = entity.getTypeName(contextMenu.entityId);
123
+ if (entity.expressId[i] === resolvedExpressId) {
124
+ entityType = entity.getTypeName(resolvedExpressId);
102
125
  break;
103
126
  }
104
127
  }
105
128
 
106
129
  if (entityType) {
107
130
  // Select all entities of the same type
131
+ // NOTE: These are original expressIds - for multi-model, should transform to globalIds
108
132
  const sameTypeIds: number[] = [];
109
133
  for (let i = 0; i < entity.count; i++) {
110
134
  if (entity.getTypeName(entity.expressId[i]) === entityType) {
@@ -115,45 +139,49 @@ export function EntityContextMenu() {
115
139
  }
116
140
 
117
141
  closeContextMenu();
118
- }, [contextMenu.entityId, ifcDataStore, setSelectedEntityIds, closeContextMenu]);
142
+ }, [resolvedExpressId, activeDataStore, setSelectedEntityIds, closeContextMenu]);
119
143
 
120
144
  const handleSelectSameStorey = useCallback(() => {
121
- if (!contextMenu.entityId || !ifcDataStore?.spatialHierarchy) {
145
+ // Use resolvedExpressId (original ID) for IfcDataStore lookups
146
+ if (!resolvedExpressId || !activeDataStore?.spatialHierarchy) {
122
147
  closeContextMenu();
123
148
  return;
124
149
  }
125
150
 
126
- const storeyId = ifcDataStore.spatialHierarchy.elementToStorey.get(contextMenu.entityId);
151
+ const storeyId = activeDataStore.spatialHierarchy.elementToStorey.get(resolvedExpressId);
127
152
  if (storeyId) {
128
- const storeyElements = ifcDataStore.spatialHierarchy.byStorey.get(storeyId);
153
+ const storeyElements = activeDataStore.spatialHierarchy.byStorey.get(storeyId);
129
154
  if (storeyElements) {
155
+ // NOTE: These are original expressIds - for multi-model, should transform to globalIds
130
156
  setSelectedEntityIds(Array.from(storeyElements));
131
157
  }
132
158
  }
133
159
 
134
160
  closeContextMenu();
135
- }, [contextMenu.entityId, ifcDataStore, setSelectedEntityIds, closeContextMenu]);
161
+ }, [resolvedExpressId, activeDataStore, setSelectedEntityIds, closeContextMenu]);
136
162
 
137
163
  const handleCopyId = useCallback(() => {
138
- if (contextMenu.entityId && ifcDataStore) {
139
- const globalId = ifcDataStore.entities.getGlobalId(contextMenu.entityId);
164
+ // Use resolvedExpressId (original ID) for IfcDataStore lookups
165
+ if (resolvedExpressId && activeDataStore) {
166
+ const globalId = activeDataStore.entities.getGlobalId(resolvedExpressId);
140
167
  if (globalId) {
141
168
  navigator.clipboard.writeText(globalId);
142
169
  }
143
170
  }
144
171
  closeContextMenu();
145
- }, [contextMenu.entityId, ifcDataStore, closeContextMenu]);
172
+ }, [resolvedExpressId, activeDataStore, closeContextMenu]);
146
173
 
147
174
  if (!contextMenu.isOpen) {
148
175
  return null;
149
176
  }
150
177
 
151
178
  // Get entity info for display
179
+ // Use resolvedExpressId (original ID) for IfcDataStore lookups
152
180
  let entityName = '';
153
181
  let entityType = '';
154
- if (contextMenu.entityId && ifcDataStore) {
155
- entityName = ifcDataStore.entities.getName(contextMenu.entityId) || '';
156
- entityType = ifcDataStore.entities.getTypeName(contextMenu.entityId) || '';
182
+ if (resolvedExpressId && activeDataStore) {
183
+ entityName = activeDataStore.entities.getName(resolvedExpressId) || '';
184
+ entityType = activeDataStore.entities.getTypeName(resolvedExpressId) || '';
157
185
  }
158
186
 
159
187
  return (