@ifc-lite/viewer 1.1.7 → 1.6.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-BjDQoB2M.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-YBtrHPu3.js +65252 -0
  13. package/dist/assets/index-v3mcCUPN.css +1 -0
  14. package/dist/assets/native-bridge-CULtTDX3.js +111 -0
  15. package/dist/assets/wasm-bridge-CjL-lSak.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,611 @@
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
+ * Property Editor component for editing IFC property values inline.
7
+ * Production-ready with keyboard support and proper UX.
8
+ */
9
+
10
+ import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
11
+ import {
12
+ Save,
13
+ X,
14
+ Plus,
15
+ Trash2,
16
+ PenLine,
17
+ Undo,
18
+ Redo,
19
+ Check,
20
+ } from 'lucide-react';
21
+ import { Button } from '@/components/ui/button';
22
+ import { Input } from '@/components/ui/input';
23
+ import { Label } from '@/components/ui/label';
24
+ import {
25
+ Select,
26
+ SelectContent,
27
+ SelectItem,
28
+ SelectTrigger,
29
+ SelectValue,
30
+ } from '@/components/ui/select';
31
+ import {
32
+ Dialog,
33
+ DialogContent,
34
+ DialogDescription,
35
+ DialogFooter,
36
+ DialogHeader,
37
+ DialogTitle,
38
+ DialogTrigger,
39
+ } from '@/components/ui/dialog';
40
+ import { Switch } from '@/components/ui/switch';
41
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
42
+ import { useViewerStore } from '@/store';
43
+ import { PropertyValueType } from '@ifc-lite/data';
44
+ import type { PropertyValue } from '@ifc-lite/mutations';
45
+
46
+ interface PropertyEditorProps {
47
+ modelId: string;
48
+ entityId: number;
49
+ psetName: string;
50
+ propName: string;
51
+ currentValue: unknown;
52
+ currentType?: PropertyValueType;
53
+ onClose?: () => void;
54
+ }
55
+
56
+ /**
57
+ * Inline property value editor with pen icon on the right.
58
+ * Supports keyboard: Enter to save, Escape to cancel.
59
+ */
60
+ export function PropertyEditor({
61
+ modelId,
62
+ entityId,
63
+ psetName,
64
+ propName,
65
+ currentValue,
66
+ currentType = PropertyValueType.String,
67
+ onClose,
68
+ }: PropertyEditorProps) {
69
+ const setProperty = useViewerStore((s) => s.setProperty);
70
+ const deleteProperty = useViewerStore((s) => s.deleteProperty);
71
+ const bumpMutationVersion = useViewerStore((s) => s.bumpMutationVersion);
72
+
73
+ const [value, setValue] = useState<string>(formatValue(currentValue));
74
+ const [valueType, setValueType] = useState<PropertyValueType>(detectValueType(currentValue, currentType));
75
+ const [isEditing, setIsEditing] = useState(false);
76
+ const inputRef = useRef<HTMLInputElement>(null);
77
+
78
+ // Focus input when entering edit mode
79
+ useEffect(() => {
80
+ if (isEditing && inputRef.current) {
81
+ inputRef.current.focus();
82
+ inputRef.current.select();
83
+ }
84
+ }, [isEditing]);
85
+
86
+ const handleSave = useCallback(() => {
87
+ const parsedValue = parseValue(value, valueType);
88
+
89
+ // Normalize model ID for legacy models
90
+ let normalizedModelId = modelId;
91
+ if (modelId === 'legacy') {
92
+ normalizedModelId = '__legacy__';
93
+ }
94
+
95
+ setProperty(normalizedModelId, entityId, psetName, propName, parsedValue, valueType);
96
+ bumpMutationVersion();
97
+ setIsEditing(false);
98
+ onClose?.();
99
+ }, [modelId, entityId, psetName, propName, value, valueType, setProperty, bumpMutationVersion, onClose]);
100
+
101
+ const handleDelete = useCallback(() => {
102
+ // Normalize model ID for legacy models
103
+ let normalizedModelId = modelId;
104
+ if (modelId === 'legacy') {
105
+ normalizedModelId = '__legacy__';
106
+ }
107
+
108
+ deleteProperty(normalizedModelId, entityId, psetName, propName);
109
+ bumpMutationVersion();
110
+ setIsEditing(false);
111
+ onClose?.();
112
+ }, [modelId, entityId, psetName, propName, deleteProperty, bumpMutationVersion, onClose]);
113
+
114
+ const handleCancel = useCallback(() => {
115
+ setValue(formatValue(currentValue));
116
+ setValueType(detectValueType(currentValue, currentType));
117
+ setIsEditing(false);
118
+ onClose?.();
119
+ }, [currentValue, currentType, onClose]);
120
+
121
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
122
+ if (e.key === 'Enter' && !e.shiftKey) {
123
+ e.preventDefault();
124
+ handleSave();
125
+ } else if (e.key === 'Escape') {
126
+ e.preventDefault();
127
+ handleCancel();
128
+ }
129
+ }, [handleSave, handleCancel]);
130
+
131
+ const displayValue = formatDisplayValue(currentValue);
132
+
133
+ // Non-editing view: value with pen icon on right (always visible)
134
+ if (!isEditing) {
135
+ return (
136
+ <div className="flex items-center gap-2 min-w-0">
137
+ <span
138
+ className="font-mono text-zinc-900 dark:text-zinc-100 select-all break-words flex-1 min-w-0 cursor-text"
139
+ onClick={() => setIsEditing(true)}
140
+ title="Click to edit"
141
+ >
142
+ {displayValue}
143
+ </span>
144
+ <Tooltip>
145
+ <TooltipTrigger asChild>
146
+ <Button
147
+ variant="ghost"
148
+ size="icon"
149
+ className="h-5 w-5 shrink-0 hover:bg-purple-100 dark:hover:bg-purple-900/30"
150
+ onClick={() => setIsEditing(true)}
151
+ >
152
+ <PenLine className="h-3 w-3 text-purple-500" />
153
+ </Button>
154
+ </TooltipTrigger>
155
+ <TooltipContent side="left">Edit property</TooltipContent>
156
+ </Tooltip>
157
+ </div>
158
+ );
159
+ }
160
+
161
+ // Editing view: inline input with type selector and action buttons
162
+ return (
163
+ <div className="flex flex-col gap-2 p-2 -mx-2 bg-purple-50/50 dark:bg-purple-950/30 border border-purple-200 dark:border-purple-800 rounded">
164
+ {/* Value input */}
165
+ <div className="flex items-center gap-2">
166
+ {valueType === PropertyValueType.Boolean || valueType === PropertyValueType.Logical ? (
167
+ <div className="flex items-center gap-2 flex-1">
168
+ <Switch
169
+ checked={value === 'true'}
170
+ onCheckedChange={(checked) => setValue(checked ? 'true' : 'false')}
171
+ />
172
+ <span className="text-xs text-zinc-500">{value === 'true' ? 'True' : 'False'}</span>
173
+ </div>
174
+ ) : (
175
+ <Input
176
+ ref={inputRef}
177
+ value={value}
178
+ onChange={(e) => setValue(e.target.value)}
179
+ onKeyDown={handleKeyDown}
180
+ className="h-7 text-xs font-mono flex-1 bg-white dark:bg-zinc-900"
181
+ placeholder="Enter value"
182
+ type={valueType === PropertyValueType.Real || valueType === PropertyValueType.Integer ? 'number' : 'text'}
183
+ step={valueType === PropertyValueType.Real ? 'any' : undefined}
184
+ />
185
+ )}
186
+
187
+ {/* Action buttons */}
188
+ <Tooltip>
189
+ <TooltipTrigger asChild>
190
+ <Button
191
+ variant="ghost"
192
+ size="icon"
193
+ className="h-6 w-6 hover:bg-green-100 dark:hover:bg-green-900/30"
194
+ onClick={handleSave}
195
+ >
196
+ <Check className="h-3.5 w-3.5 text-green-600" />
197
+ </Button>
198
+ </TooltipTrigger>
199
+ <TooltipContent>Save (Enter)</TooltipContent>
200
+ </Tooltip>
201
+ <Tooltip>
202
+ <TooltipTrigger asChild>
203
+ <Button
204
+ variant="ghost"
205
+ size="icon"
206
+ className="h-6 w-6 hover:bg-zinc-200 dark:hover:bg-zinc-700"
207
+ onClick={handleCancel}
208
+ >
209
+ <X className="h-3.5 w-3.5 text-zinc-500" />
210
+ </Button>
211
+ </TooltipTrigger>
212
+ <TooltipContent>Cancel (Esc)</TooltipContent>
213
+ </Tooltip>
214
+ <Tooltip>
215
+ <TooltipTrigger asChild>
216
+ <Button
217
+ variant="ghost"
218
+ size="icon"
219
+ className="h-6 w-6 hover:bg-red-100 dark:hover:bg-red-900/30"
220
+ onClick={handleDelete}
221
+ >
222
+ <Trash2 className="h-3.5 w-3.5 text-red-500" />
223
+ </Button>
224
+ </TooltipTrigger>
225
+ <TooltipContent>Delete property</TooltipContent>
226
+ </Tooltip>
227
+ </div>
228
+
229
+ {/* Type selector - always visible */}
230
+ <div className="flex flex-wrap gap-1">
231
+ {[
232
+ { type: PropertyValueType.String, label: 'String' },
233
+ { type: PropertyValueType.Label, label: 'Label' },
234
+ { type: PropertyValueType.Identifier, label: 'ID' },
235
+ { type: PropertyValueType.Real, label: 'Real' },
236
+ { type: PropertyValueType.Integer, label: 'Int' },
237
+ { type: PropertyValueType.Boolean, label: 'Bool' },
238
+ ].map(({ type, label }) => (
239
+ <Button
240
+ key={type}
241
+ variant={valueType === type ? 'default' : 'outline'}
242
+ size="sm"
243
+ className="h-5 px-2 text-[10px]"
244
+ onClick={() => {
245
+ setValueType(type);
246
+ // Convert value if switching to/from boolean
247
+ if (type === PropertyValueType.Boolean || type === PropertyValueType.Logical) {
248
+ const boolVal = value.toLowerCase() === 'true' || value === '1' || value === 'yes';
249
+ setValue(boolVal ? 'true' : 'false');
250
+ }
251
+ }}
252
+ >
253
+ {label}
254
+ </Button>
255
+ ))}
256
+ </div>
257
+ </div>
258
+ );
259
+ }
260
+
261
+ interface NewPropertyDialogProps {
262
+ modelId: string;
263
+ entityId: number;
264
+ existingPsets: string[];
265
+ }
266
+
267
+ /**
268
+ * Dialog for adding a new property
269
+ */
270
+ export function NewPropertyDialog({ modelId, entityId, existingPsets }: NewPropertyDialogProps) {
271
+ const setProperty = useViewerStore((s) => s.setProperty);
272
+ const createPropertySet = useViewerStore((s) => s.createPropertySet);
273
+ const bumpMutationVersion = useViewerStore((s) => s.bumpMutationVersion);
274
+
275
+ const [open, setOpen] = useState(false);
276
+ const [psetName, setPsetName] = useState('');
277
+ const [isNewPset, setIsNewPset] = useState(false);
278
+ const [propName, setPropName] = useState('');
279
+ const [value, setValue] = useState('');
280
+ const [valueType, setValueType] = useState<PropertyValueType>(PropertyValueType.String);
281
+
282
+ const commonPsets = useMemo(() => [
283
+ 'Pset_WallCommon',
284
+ 'Pset_DoorCommon',
285
+ 'Pset_WindowCommon',
286
+ 'Pset_SlabCommon',
287
+ 'Pset_BeamCommon',
288
+ 'Pset_ColumnCommon',
289
+ 'Pset_BuildingElementProxyCommon',
290
+ 'Pset_SpaceCommon',
291
+ ], []);
292
+
293
+ const handleSubmit = useCallback(() => {
294
+ if (!psetName || !propName) return;
295
+
296
+ // Normalize model ID for legacy models
297
+ let normalizedModelId = modelId;
298
+ if (modelId === 'legacy') {
299
+ normalizedModelId = '__legacy__';
300
+ }
301
+
302
+ const parsedValue = parseValue(value, valueType);
303
+
304
+ if (isNewPset) {
305
+ createPropertySet(normalizedModelId, entityId, psetName, [
306
+ { name: propName, value: parsedValue, type: valueType },
307
+ ]);
308
+ } else {
309
+ setProperty(normalizedModelId, entityId, psetName, propName, parsedValue, valueType);
310
+ }
311
+
312
+ bumpMutationVersion();
313
+
314
+ // Reset form
315
+ setPsetName('');
316
+ setPropName('');
317
+ setValue('');
318
+ setValueType(PropertyValueType.String);
319
+ setIsNewPset(false);
320
+ setOpen(false);
321
+ }, [modelId, entityId, psetName, propName, value, valueType, isNewPset, setProperty, createPropertySet, bumpMutationVersion]);
322
+
323
+ return (
324
+ <Dialog open={open} onOpenChange={setOpen}>
325
+ <DialogTrigger asChild>
326
+ <Button variant="outline" size="sm" className="h-7">
327
+ <Plus className="h-3 w-3 mr-1" />
328
+ Add Property
329
+ </Button>
330
+ </DialogTrigger>
331
+ <DialogContent className="sm:max-w-md">
332
+ <DialogHeader>
333
+ <DialogTitle>Add Property</DialogTitle>
334
+ <DialogDescription>
335
+ Add a new property to this entity
336
+ </DialogDescription>
337
+ </DialogHeader>
338
+ <div className="grid gap-4 py-4">
339
+ <div className="flex items-center gap-4">
340
+ <Label className="w-24">New Pset</Label>
341
+ <Switch checked={isNewPset} onCheckedChange={setIsNewPset} />
342
+ </div>
343
+ <div className="flex items-center gap-4">
344
+ <Label className="w-24">Property Set</Label>
345
+ {isNewPset ? (
346
+ <Input
347
+ value={psetName}
348
+ onChange={(e) => setPsetName(e.target.value)}
349
+ placeholder="e.g., Pset_CustomProperties"
350
+ />
351
+ ) : (
352
+ <Select value={psetName} onValueChange={setPsetName}>
353
+ <SelectTrigger>
354
+ <SelectValue placeholder="Select property set" />
355
+ </SelectTrigger>
356
+ <SelectContent>
357
+ {existingPsets.map((name) => (
358
+ <SelectItem key={name} value={name}>
359
+ {name}
360
+ </SelectItem>
361
+ ))}
362
+ {commonPsets
363
+ .filter((p) => !existingPsets.includes(p))
364
+ .map((name) => (
365
+ <SelectItem key={name} value={name}>
366
+ {name} (new)
367
+ </SelectItem>
368
+ ))}
369
+ </SelectContent>
370
+ </Select>
371
+ )}
372
+ </div>
373
+ <div className="flex items-center gap-4">
374
+ <Label className="w-24">Property</Label>
375
+ <Input
376
+ value={propName}
377
+ onChange={(e) => setPropName(e.target.value)}
378
+ placeholder="e.g., FireRating"
379
+ />
380
+ </div>
381
+ <div className="flex items-center gap-4">
382
+ <Label className="w-24">Type</Label>
383
+ <Select
384
+ value={valueType.toString()}
385
+ onValueChange={(v) => setValueType(parseInt(v) as PropertyValueType)}
386
+ >
387
+ <SelectTrigger>
388
+ <SelectValue />
389
+ </SelectTrigger>
390
+ <SelectContent>
391
+ <SelectItem value={PropertyValueType.String.toString()}>String</SelectItem>
392
+ <SelectItem value={PropertyValueType.Real.toString()}>Real</SelectItem>
393
+ <SelectItem value={PropertyValueType.Integer.toString()}>Integer</SelectItem>
394
+ <SelectItem value={PropertyValueType.Boolean.toString()}>Boolean</SelectItem>
395
+ <SelectItem value={PropertyValueType.Label.toString()}>Label</SelectItem>
396
+ <SelectItem value={PropertyValueType.Identifier.toString()}>Identifier</SelectItem>
397
+ </SelectContent>
398
+ </Select>
399
+ </div>
400
+ <div className="flex items-center gap-4">
401
+ <Label className="w-24">Value</Label>
402
+ {valueType === PropertyValueType.Boolean ? (
403
+ <Switch
404
+ checked={value === 'true'}
405
+ onCheckedChange={(checked) => setValue(checked ? 'true' : 'false')}
406
+ />
407
+ ) : (
408
+ <Input
409
+ value={value}
410
+ onChange={(e) => setValue(e.target.value)}
411
+ placeholder="Property value"
412
+ type={valueType === PropertyValueType.Real || valueType === PropertyValueType.Integer ? 'number' : 'text'}
413
+ />
414
+ )}
415
+ </div>
416
+ </div>
417
+ <DialogFooter>
418
+ <Button variant="outline" onClick={() => setOpen(false)}>
419
+ Cancel
420
+ </Button>
421
+ <Button onClick={handleSubmit} disabled={!psetName || !propName}>
422
+ Add Property
423
+ </Button>
424
+ </DialogFooter>
425
+ </DialogContent>
426
+ </Dialog>
427
+ );
428
+ }
429
+
430
+ interface UndoRedoButtonsProps {
431
+ modelId: string;
432
+ }
433
+
434
+ /**
435
+ * Undo/Redo buttons for property mutations
436
+ */
437
+ export function UndoRedoButtons({ modelId }: UndoRedoButtonsProps) {
438
+ const canUndo = useViewerStore((s) => s.canUndo);
439
+ const canRedo = useViewerStore((s) => s.canRedo);
440
+ const undo = useViewerStore((s) => s.undo);
441
+ const redo = useViewerStore((s) => s.redo);
442
+
443
+ // Normalize model ID for legacy models
444
+ let normalizedModelId = modelId;
445
+ if (modelId === 'legacy') {
446
+ normalizedModelId = '__legacy__';
447
+ }
448
+
449
+ const handleUndo = useCallback(() => {
450
+ undo(normalizedModelId);
451
+ }, [normalizedModelId, undo]);
452
+
453
+ const handleRedo = useCallback(() => {
454
+ redo(normalizedModelId);
455
+ }, [normalizedModelId, redo]);
456
+
457
+ return (
458
+ <div className="flex items-center gap-1">
459
+ <Tooltip>
460
+ <TooltipTrigger asChild>
461
+ <Button
462
+ variant="ghost"
463
+ size="icon"
464
+ className="h-7 w-7"
465
+ onClick={handleUndo}
466
+ disabled={!canUndo(normalizedModelId)}
467
+ >
468
+ <Undo className="h-4 w-4" />
469
+ </Button>
470
+ </TooltipTrigger>
471
+ <TooltipContent>Undo</TooltipContent>
472
+ </Tooltip>
473
+ <Tooltip>
474
+ <TooltipTrigger asChild>
475
+ <Button
476
+ variant="ghost"
477
+ size="icon"
478
+ className="h-7 w-7"
479
+ onClick={handleRedo}
480
+ disabled={!canRedo(normalizedModelId)}
481
+ >
482
+ <Redo className="h-4 w-4" />
483
+ </Button>
484
+ </TooltipTrigger>
485
+ <TooltipContent>Redo</TooltipContent>
486
+ </Tooltip>
487
+ </div>
488
+ );
489
+ }
490
+
491
+ // Helper functions
492
+
493
+ /**
494
+ * Extract the raw value from typed IFC values.
495
+ * Handles: arrays like [IFCLABEL, value], strings like "IFCLABEL,value"
496
+ */
497
+ function extractRawValue(value: unknown): unknown {
498
+ if (value === null || value === undefined) return value;
499
+
500
+ // Handle typed value arrays [IFCTYPENAME, actualValue]
501
+ if (Array.isArray(value) && value.length === 2 && typeof value[0] === 'string' && value[0].toUpperCase().startsWith('IFC')) {
502
+ return value[1];
503
+ }
504
+
505
+ // Handle string format "IFCTYPENAME,actualValue"
506
+ if (typeof value === 'string') {
507
+ const match = value.match(/^(IFC[A-Z0-9_]+),(.*)$/i);
508
+ if (match) {
509
+ return match[2]; // Return just the value part
510
+ }
511
+ }
512
+
513
+ return value;
514
+ }
515
+
516
+ function formatValue(value: unknown): string {
517
+ const raw = extractRawValue(value);
518
+ if (raw === null || raw === undefined) return '';
519
+ if (typeof raw === 'boolean') return raw ? 'true' : 'false';
520
+ if (typeof raw === 'number') return raw.toString();
521
+ if (Array.isArray(raw)) return JSON.stringify(raw);
522
+ return String(raw);
523
+ }
524
+
525
+ function formatDisplayValue(value: unknown): string {
526
+ const raw = extractRawValue(value);
527
+ if (raw === null || raw === undefined) return '—';
528
+ if (typeof raw === 'boolean') return raw ? 'True' : 'False';
529
+ if (typeof raw === 'number') {
530
+ return Number.isInteger(raw)
531
+ ? raw.toLocaleString()
532
+ : raw.toLocaleString(undefined, { maximumFractionDigits: 6 });
533
+ }
534
+ if (Array.isArray(raw)) return JSON.stringify(raw);
535
+
536
+ // Handle boolean strings
537
+ const strVal = String(raw);
538
+ if (strVal === '.T.' || strVal.toUpperCase() === '.T.') return 'True';
539
+ if (strVal === '.F.' || strVal.toUpperCase() === '.F.') return 'False';
540
+ if (strVal === '.U.' || strVal.toUpperCase() === '.U.') return 'Unknown';
541
+ return strVal;
542
+ }
543
+
544
+ function detectValueType(value: unknown, fallback: PropertyValueType): PropertyValueType {
545
+ // First check if it's a typed value and extract the type
546
+ if (Array.isArray(value) && value.length === 2 && typeof value[0] === 'string') {
547
+ const typeName = value[0].toUpperCase();
548
+ if (typeName === 'IFCBOOLEAN' || typeName === 'IFCLOGICAL') return PropertyValueType.Boolean;
549
+ if (typeName === 'IFCREAL') return PropertyValueType.Real;
550
+ if (typeName === 'IFCINTEGER') return PropertyValueType.Integer;
551
+ if (typeName === 'IFCIDENTIFIER') return PropertyValueType.Identifier;
552
+ if (typeName === 'IFCLABEL') return PropertyValueType.Label;
553
+ if (typeName === 'IFCTEXT') return PropertyValueType.String;
554
+ }
555
+
556
+ // Check string format "IFCTYPE,value"
557
+ if (typeof value === 'string') {
558
+ const match = value.match(/^(IFC[A-Z0-9_]+),/i);
559
+ if (match) {
560
+ const typeName = match[1].toUpperCase();
561
+ if (typeName === 'IFCBOOLEAN' || typeName === 'IFCLOGICAL') return PropertyValueType.Boolean;
562
+ if (typeName === 'IFCREAL') return PropertyValueType.Real;
563
+ if (typeName === 'IFCINTEGER') return PropertyValueType.Integer;
564
+ if (typeName === 'IFCIDENTIFIER') return PropertyValueType.Identifier;
565
+ if (typeName === 'IFCLABEL') return PropertyValueType.Label;
566
+ if (typeName === 'IFCTEXT') return PropertyValueType.String;
567
+ }
568
+
569
+ // Check for boolean enum values
570
+ const upper = value.toUpperCase();
571
+ if (upper === '.T.' || upper === '.F.' || upper === '.U.') {
572
+ return PropertyValueType.Boolean;
573
+ }
574
+ }
575
+
576
+ // Check raw value type
577
+ const raw = extractRawValue(value);
578
+ if (typeof raw === 'boolean') return PropertyValueType.Boolean;
579
+ if (typeof raw === 'number') {
580
+ return Number.isInteger(raw) ? PropertyValueType.Integer : PropertyValueType.Real;
581
+ }
582
+
583
+ return fallback;
584
+ }
585
+
586
+ function getTypeName(type: PropertyValueType): string {
587
+ switch (type) {
588
+ case PropertyValueType.String: return 'String';
589
+ case PropertyValueType.Label: return 'Label';
590
+ case PropertyValueType.Identifier: return 'Identifier';
591
+ case PropertyValueType.Real: return 'Real';
592
+ case PropertyValueType.Integer: return 'Integer';
593
+ case PropertyValueType.Boolean: return 'Boolean';
594
+ case PropertyValueType.Logical: return 'Logical';
595
+ default: return 'String';
596
+ }
597
+ }
598
+
599
+ function parseValue(value: string, type: PropertyValueType): PropertyValue {
600
+ switch (type) {
601
+ case PropertyValueType.Real:
602
+ return parseFloat(value) || 0;
603
+ case PropertyValueType.Integer:
604
+ return parseInt(value, 10) || 0;
605
+ case PropertyValueType.Boolean:
606
+ case PropertyValueType.Logical:
607
+ return value.toLowerCase() === 'true';
608
+ default:
609
+ return value;
610
+ }
611
+ }