@ifc-lite/viewer 1.17.6 → 1.18.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 (143) hide show
  1. package/.turbo/turbo-build.log +17 -14
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +513 -0
  4. package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  5. package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
  6. package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
  7. package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
  8. package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
  9. package/dist/assets/index-COnQRuqY.css +1 -0
  10. package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
  11. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  12. package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
  13. package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
  14. package/dist/index.html +6 -6
  15. package/package.json +10 -10
  16. package/src/apache-arrow.d.ts +30 -0
  17. package/src/components/viewer/AddElementPanel.tsx +758 -0
  18. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  19. package/src/components/viewer/ChatPanel.tsx +64 -2
  20. package/src/components/viewer/CommandPalette.tsx +56 -7
  21. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  22. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  23. package/src/components/viewer/ExportDialog.tsx +19 -1
  24. package/src/components/viewer/MainToolbar.tsx +69 -10
  25. package/src/components/viewer/PropertiesPanel.tsx +222 -22
  26. package/src/components/viewer/SearchInline.tsx +669 -0
  27. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  28. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  29. package/src/components/viewer/SearchModal.text.tsx +388 -0
  30. package/src/components/viewer/SearchModal.tsx +235 -0
  31. package/src/components/viewer/ToolOverlays.tsx +5 -0
  32. package/src/components/viewer/ViewerLayout.tsx +24 -4
  33. package/src/components/viewer/Viewport.tsx +11 -1
  34. package/src/components/viewer/ViewportContainer.tsx +2 -0
  35. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  36. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  37. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  38. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  39. package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
  40. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  41. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  42. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  43. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  44. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  45. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  46. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  47. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  48. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  49. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  50. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  51. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  52. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  53. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  54. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  55. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  56. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  57. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  58. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  59. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  60. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  61. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  62. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  63. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  64. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  65. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  66. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  67. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  68. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  69. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  70. package/src/components/viewer/selectionHandlers.ts +446 -0
  71. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  72. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  73. package/src/components/viewer/useMouseControls.ts +9 -1
  74. package/src/hooks/useIfcLoader.ts +22 -10
  75. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  76. package/src/hooks/useSandbox.ts +1 -1
  77. package/src/hooks/useSearchIndex.ts +125 -0
  78. package/src/index.css +66 -0
  79. package/src/lib/llm/system-prompt.test.ts +14 -0
  80. package/src/lib/llm/system-prompt.ts +102 -1
  81. package/src/lib/llm/types.ts +6 -0
  82. package/src/lib/recent-files.ts +38 -4
  83. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  84. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  85. package/src/lib/scripts/templates.ts +7 -0
  86. package/src/lib/search/common-ifc-types.ts +36 -0
  87. package/src/lib/search/filter-evaluate.test.ts +537 -0
  88. package/src/lib/search/filter-evaluate.ts +610 -0
  89. package/src/lib/search/filter-rules.test.ts +119 -0
  90. package/src/lib/search/filter-rules.ts +198 -0
  91. package/src/lib/search/filter-schema.test.ts +233 -0
  92. package/src/lib/search/filter-schema.ts +146 -0
  93. package/src/lib/search/recent-searches.test.ts +116 -0
  94. package/src/lib/search/recent-searches.ts +93 -0
  95. package/src/lib/search/result-export.test.ts +101 -0
  96. package/src/lib/search/result-export.ts +104 -0
  97. package/src/lib/search/saved-filters.test.ts +118 -0
  98. package/src/lib/search/saved-filters.ts +154 -0
  99. package/src/lib/search/tier0-scan.test.ts +196 -0
  100. package/src/lib/search/tier0-scan.ts +237 -0
  101. package/src/lib/search/tier1-index.test.ts +242 -0
  102. package/src/lib/search/tier1-index.ts +448 -0
  103. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  104. package/src/sdk/adapters/export-adapter.ts +404 -1
  105. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  106. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  107. package/src/sdk/adapters/model-compat.ts +8 -2
  108. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  109. package/src/sdk/adapters/store-adapter.ts +201 -0
  110. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  111. package/src/sdk/local-backend.ts +16 -8
  112. package/src/services/desktop-export.ts +3 -1
  113. package/src/services/desktop-native-metadata.ts +41 -18
  114. package/src/services/file-dialog.ts +4 -1
  115. package/src/services/tauri-modules.d.ts +25 -0
  116. package/src/store/basketVisibleSet.ts +3 -0
  117. package/src/store/globalId.ts +4 -1
  118. package/src/store/index.ts +70 -1
  119. package/src/store/slices/addElementMeshes.ts +365 -0
  120. package/src/store/slices/addElementSlice.ts +275 -0
  121. package/src/store/slices/annotationsSlice.test.ts +133 -0
  122. package/src/store/slices/annotationsSlice.ts +251 -0
  123. package/src/store/slices/dataSlice.test.ts +23 -4
  124. package/src/store/slices/dataSlice.ts +1 -1
  125. package/src/store/slices/modelSlice.test.ts +67 -9
  126. package/src/store/slices/modelSlice.ts +39 -7
  127. package/src/store/slices/mutationSlice.ts +964 -3
  128. package/src/store/slices/overlayCompositor.test.ts +164 -0
  129. package/src/store/slices/overlaySlice.test.ts +93 -0
  130. package/src/store/slices/overlaySlice.ts +151 -0
  131. package/src/store/slices/pinboardSlice.test.ts +6 -1
  132. package/src/store/slices/playbackSlice.ts +128 -0
  133. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  134. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  135. package/src/store/slices/scheduleSlice.test.ts +694 -0
  136. package/src/store/slices/scheduleSlice.ts +1330 -0
  137. package/src/store/slices/searchSlice.test.ts +342 -0
  138. package/src/store/slices/searchSlice.ts +341 -0
  139. package/src/store/slices/selectionSlice.test.ts +46 -0
  140. package/src/store/slices/selectionSlice.ts +20 -0
  141. package/src/store.ts +14 -0
  142. package/dist/assets/index-_bfZsDCC.css +0 -1
  143. package/dist/assets/sandbox-C8575tul.js +0 -5951
@@ -225,6 +225,13 @@ export function BulkPropertyEditor({ trigger }: BulkPropertyEditorProps) {
225
225
  // Yield to browser so dialog shell + spinner paint first
226
226
  initTimerRef.current = setTimeout(() => {
227
227
  const dataStore = selectedModel.ifcDataStore;
228
+ // The early return above already gates on `selectedModel?.ifcDataStore`,
229
+ // but the closure-captured `selectedModel` reference is union-typed,
230
+ // so re-narrow here for the timeout callback.
231
+ if (!dataStore) {
232
+ setIsInitializing(false);
233
+ return;
234
+ }
228
235
  const entities = dataStore.entities;
229
236
 
230
237
  // Storeys
@@ -79,11 +79,24 @@ const MAX_INLINE_IMAGE_DATA_URL_CHARS = 1_200_000;
79
79
  const MAX_ATTACHMENTS_PER_MESSAGE = 6;
80
80
  const MAX_TEXT_ATTACHMENT_BYTES = 512_000;
81
81
  const MAX_IMAGE_ATTACHMENT_BYTES = 8_000_000;
82
+ /** Anthropic's PDF content-block limit is ~32 MB; keep our upload cap lower. */
83
+ const MAX_PDF_ATTACHMENT_BYTES = 16_000_000;
82
84
 
83
85
  function createAttachmentId(): string {
84
86
  return crypto.randomUUID();
85
87
  }
86
88
 
89
+ /** Convert an ArrayBuffer (binary file) to raw base64 — no data-URL prefix. */
90
+ function arrayBufferToBase64(buffer: ArrayBuffer): string {
91
+ const bytes = new Uint8Array(buffer);
92
+ let binary = '';
93
+ const chunk = 0x8000;
94
+ for (let i = 0; i < bytes.length; i += chunk) {
95
+ binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
96
+ }
97
+ return btoa(binary);
98
+ }
99
+
87
100
  interface ChatSendOptions {
88
101
  continuationBase?: string;
89
102
  intent?: ScriptMutationIntent;
@@ -1019,7 +1032,54 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1019
1032
  remainingSlots -= 1;
1020
1033
  continue;
1021
1034
  }
1022
- // Only accept text-based files
1035
+ // PDFs are supported by Claude as native document content blocks.
1036
+ // Route them separately from text attachments so the chat request
1037
+ // can emit the correct multimodal block type.
1038
+ if (file.name.match(/\.pdf$/i) || file.type === 'application/pdf') {
1039
+ if (!supportsFileAttachments) {
1040
+ setChatError('Selected model does not support file attachments. Switch model to attach PDFs.');
1041
+ continue;
1042
+ }
1043
+ if (file.size > MAX_PDF_ATTACHMENT_BYTES) {
1044
+ setChatError(`PDF attachments must be smaller than ${Math.round(MAX_PDF_ATTACHMENT_BYTES / 1_000_000)} MB.`);
1045
+ continue;
1046
+ }
1047
+ const buffer = await file.arrayBuffer();
1048
+ const base64 = arrayBufferToBase64(buffer);
1049
+ const attachment: FileAttachment = {
1050
+ id: createAttachmentId(),
1051
+ name: file.name,
1052
+ type: 'application/pdf',
1053
+ size: file.size,
1054
+ pdfBase64: base64,
1055
+ isPdf: true,
1056
+ };
1057
+ addAttachment(attachment);
1058
+ remainingSlots -= 1;
1059
+ continue;
1060
+ }
1061
+ // Excel / ODS binaries — we can't parse them yet, but we don't want
1062
+ // to silently drop them. Register a metadata-only attachment so the
1063
+ // user (and the LLM via the system prompt) know it's there and can
1064
+ // suggest exporting as CSV.
1065
+ if (file.name.match(/\.(xlsx|xls|ods)$/i)) {
1066
+ if (!supportsFileAttachments) {
1067
+ setChatError('Selected model does not support file attachments. Switch model to attach spreadsheets.');
1068
+ continue;
1069
+ }
1070
+ const attachment: FileAttachment = {
1071
+ id: createAttachmentId(),
1072
+ name: file.name,
1073
+ type: file.type || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1074
+ size: file.size,
1075
+ isSpreadsheetBinary: true,
1076
+ textContent: `[Binary spreadsheet ${file.name} (${Math.round(file.size / 1024)} KB). Export to CSV for full content access.]`,
1077
+ };
1078
+ addAttachment(attachment);
1079
+ remainingSlots -= 1;
1080
+ continue;
1081
+ }
1082
+ // Text-based files — CSV, TSV, JSON, TXT
1023
1083
  if (!file.name.match(/\.(csv|json|txt|tsv)$/i)) continue;
1024
1084
  if (!supportsFileAttachments) {
1025
1085
  setChatError('Selected model does not support file attachments. Switch model to attach files.');
@@ -1117,7 +1177,9 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1117
1177
  const modelSupportsImages = modelForUi?.supportsImages ?? false;
1118
1178
  const modelSupportsFiles = modelForUi?.supportsFileAttachments ?? true;
1119
1179
  const attachmentAccept = [
1120
- modelSupportsFiles ? '.csv,.json,.txt,.tsv' : '',
1180
+ modelSupportsFiles
1181
+ ? '.csv,.json,.txt,.tsv,.pdf,application/pdf,.xlsx,.xls,.ods,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel,application/vnd.oasis.opendocument.spreadsheet'
1182
+ : '',
1121
1183
  modelSupportsImages ? 'image/*' : '',
1122
1184
  ].filter(Boolean).join(',');
1123
1185
  const canAttachInput = modelSupportsFiles || modelSupportsImages;
@@ -53,6 +53,11 @@ import {
53
53
  FolderOpen,
54
54
  Clock,
55
55
  Save,
56
+ CalendarClock,
57
+ CalendarPlus,
58
+ Sparkles,
59
+ Eraser,
60
+ MapPin,
56
61
  } from 'lucide-react';
57
62
  import { cn } from '@/lib/utils';
58
63
  import { useViewerStore } from '@/store';
@@ -207,23 +212,28 @@ function activateRightPanel(panel: 'bcf' | 'ids' | 'lens') {
207
212
  // If was active → all closed → falls back to Properties
208
213
  }
209
214
 
210
- /** Exclusively activate a bottom panel (Script / List).
215
+ /** Exclusively activate a bottom panel (Script / List / Gantt).
211
216
  * Closes the other first so the if-else chain in ViewerLayout renders it.
212
217
  * If the target is already active, closes it. */
213
- function activateBottomPanel(panel: 'script' | 'list') {
218
+ function activateBottomPanel(panel: 'script' | 'list' | 'gantt') {
214
219
  const s = useViewerStore.getState();
215
- const isActive = panel === 'script' ? s.scriptPanelVisible : s.listPanelVisible;
220
+ const isActive =
221
+ panel === 'script' ? s.scriptPanelVisible
222
+ : panel === 'list' ? s.listPanelVisible
223
+ : s.ganttPanelVisible;
216
224
 
217
225
  closeActiveAnalysisExtension();
218
226
 
219
- // Close all bottom panels
227
+ // Close all bottom panels — only one slots into the bottom strip at a time.
220
228
  s.setScriptPanelVisible(false);
221
229
  s.setListPanelVisible(false);
230
+ s.setGanttPanelVisible(false);
222
231
 
223
232
  if (!isActive) {
224
233
  s.setRightPanelCollapsed(false);
225
234
  if (panel === 'script') s.setScriptPanelVisible(true);
226
- else s.setListPanelVisible(true);
235
+ else if (panel === 'list') s.setListPanelVisible(true);
236
+ else s.setGanttPanelVisible(true);
227
237
  }
228
238
  }
229
239
 
@@ -322,6 +332,10 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
322
332
  action: () => { useViewerStore.getState().setActiveTool('measure'); } },
323
333
  { id: 'tool:section', label: 'Section', keywords: 'clip cut plane', category: 'Tools', icon: Scissors, shortcut: 'X',
324
334
  action: () => { useViewerStore.getState().setActiveTool('section'); } },
335
+ { id: 'tool:annotate', label: 'Annotate', keywords: 'pin note comment marker', category: 'Tools', icon: MapPin, shortcut: 'P',
336
+ action: () => { useViewerStore.getState().setActiveTool('annotate'); } },
337
+ { id: 'tool:add-element', label: 'Add Element', keywords: 'wall slab beam column place drop new add element generic', category: 'Tools', icon: Box,
338
+ action: () => { useViewerStore.getState().setActiveTool('addElement'); } },
325
339
  );
326
340
 
327
341
  // ── Visibility ──
@@ -362,7 +376,7 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
362
376
 
363
377
  // ── Panels ──
364
378
  c.push(
365
- { id: 'panel:properties', label: 'Properties', keywords: 'attributes panel right', category: 'Panels', icon: Layout,
379
+ { id: 'panel:properties', label: 'Inspector', keywords: 'properties attributes material classification schedule task panel right', category: 'Panels', icon: Layout,
366
380
  action: () => { const s = useViewerStore.getState(); s.setRightPanelCollapsed(!s.rightPanelCollapsed); } },
367
381
  { id: 'panel:tree', label: 'Spatial Tree', keywords: 'hierarchy left panel', category: 'Panels', icon: TreeDeciduous,
368
382
  action: () => { const s = useViewerStore.getState(); s.setLeftPanelCollapsed(!s.leftPanelCollapsed); } },
@@ -372,12 +386,47 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
372
386
  action: () => { activateRightPanel('bcf'); } },
373
387
  { id: 'panel:ids', label: 'IDS Validation', keywords: 'information delivery specification check', category: 'Panels', icon: ClipboardCheck,
374
388
  action: () => { activateRightPanel('ids'); } },
375
- { id: 'panel:lists', label: 'Entity Lists', keywords: 'table spreadsheet schedule', category: 'Panels', icon: FileSpreadsheet,
389
+ { id: 'panel:lists', label: 'Entity Lists', keywords: 'table spreadsheet', category: 'Panels', icon: FileSpreadsheet,
376
390
  action: () => { activateBottomPanel('list'); } },
391
+ { id: 'panel:gantt', label: 'Construction Schedule (Gantt)', keywords: '4d timeline tasks ifctask sequence playback animation', category: 'Panels', icon: CalendarClock,
392
+ action: () => { activateBottomPanel('gantt'); } },
377
393
  { id: 'panel:lens', label: 'Lens Rules', keywords: 'color filter highlight', category: 'Panels', icon: Palette,
378
394
  action: () => { activateRightPanel('lens'); } },
379
395
  );
380
396
 
397
+ // ── Schedule / 4D (Tools) ─────────────────────────────
398
+ c.push(
399
+ { id: 'schedule:generate', label: 'Generate Schedule from Storeys…',
400
+ keywords: '4d ifctask construction sequence storey building create gantt',
401
+ category: 'Tools', icon: CalendarPlus,
402
+ action: () => {
403
+ // Make sure the Gantt panel is mounted so the dialog has a host
404
+ // before flipping the dialog flag. Order matters — closing other
405
+ // panels first prevents the bottom strip from rendering them.
406
+ const s = useViewerStore.getState();
407
+ if (!s.ganttPanelVisible) activateBottomPanel('gantt');
408
+ // Same tick is fine — the dialog is portalled via Radix and doesn't
409
+ // depend on GanttPanel finishing its first render.
410
+ useViewerStore.getState().setGenerateScheduleDialogOpen(true);
411
+ } },
412
+ { id: 'schedule:toggle-animation', label: 'Toggle 4D Construction Animation',
413
+ keywords: 'play pause schedule task gantt simulation',
414
+ category: 'Visibility', icon: Sparkles,
415
+ action: () => {
416
+ const s = useViewerStore.getState();
417
+ s.setAnimationEnabled(!s.animationEnabled);
418
+ } },
419
+ { id: 'schedule:reset', label: 'Reset Schedule (Clear 4D Data)',
420
+ keywords: 'remove gantt tasks ifctask delete clear',
421
+ category: 'Tools', icon: Eraser,
422
+ action: () => {
423
+ const s = useViewerStore.getState();
424
+ s.setScheduleData(null);
425
+ s.setAnimationEnabled(false);
426
+ s.pauseSchedule();
427
+ } },
428
+ );
429
+
381
430
  // ── Export ──
382
431
  c.push(
383
432
  { id: 'export:screenshot', label: 'Screenshot', keywords: 'capture png image viewport', category: 'Export', icon: Camera,
@@ -18,8 +18,11 @@ import {
18
18
  Maximize2,
19
19
  Building2,
20
20
  Save,
21
+ Trash2,
22
+ CopyPlus,
21
23
  } from 'lucide-react';
22
24
  import { useViewerStore, resolveEntityRef } from '@/store';
25
+ import type { DuplicateDirection } from '@/store/slices/mutationSlice';
23
26
  import { resetVisibilityForHomeFromStore } from '@/store/homeView';
24
27
  import {
25
28
  executeBasketSet,
@@ -28,6 +31,7 @@ import {
28
31
  executeBasketSaveView,
29
32
  } from '@/store/basket/basketCommands';
30
33
  import { useIfc } from '@/hooks/useIfc';
34
+ import { toast } from '@/components/ui/toast';
31
35
 
32
36
  export function EntityContextMenu() {
33
37
  const contextMenu = useViewerStore((s) => s.contextMenu);
@@ -36,6 +40,10 @@ export function EntityContextMenu() {
36
40
  const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
37
41
  const setSelectedEntityIds = useViewerStore((s) => s.setSelectedEntityIds);
38
42
  const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks);
43
+ // Store-level mutations
44
+ const removeEntity = useViewerStore((s) => s.removeEntity);
45
+ const duplicateEntity = useViewerStore((s) => s.duplicateEntity);
46
+ const getMutationView = useViewerStore((s) => s.getMutationView);
39
47
  // Basket actions
40
48
  const menuRef = useRef<HTMLDivElement>(null);
41
49
  const { ifcDataStore, models } = useIfc();
@@ -206,6 +214,63 @@ export function EntityContextMenu() {
206
214
  closeContextMenu();
207
215
  }, [resolvedExpressId, activeDataStore, closeContextMenu]);
208
216
 
217
+ // Right-clicked entity's type — used in the toast message.
218
+ const contextEntityType = useMemo(() => {
219
+ if (!resolvedExpressId || !activeDataStore) return '';
220
+ return activeDataStore.entities.getTypeName(resolvedExpressId) || '';
221
+ }, [resolvedExpressId, activeDataStore]);
222
+
223
+ // Mutation view is required to drive bim.store.* — native-metadata-only
224
+ // models don't have one, so the Delete option stays hidden there.
225
+ const canEdit = useMemo(() => {
226
+ if (!contextEntityRef) return false;
227
+ return getMutationView(contextEntityRef.modelId) !== null;
228
+ }, [contextEntityRef, getMutationView]);
229
+
230
+ const handleDuplicate = useCallback(
231
+ (direction: DuplicateDirection = '+X') => {
232
+ if (!contextEntityRef || !canEdit) {
233
+ closeContextMenu();
234
+ return;
235
+ }
236
+ const result = duplicateEntity(contextEntityRef.modelId, contextEntityRef.expressId, direction);
237
+ if ('error' in result) {
238
+ toast.error(`Couldn't duplicate: ${result.error}`);
239
+ } else {
240
+ // Move selection onto the new entity so the property panel
241
+ // refreshes and the user can keep iterating (Cmd+D again
242
+ // duplicates the duplicate, like a stamp tool).
243
+ setSelectedEntityId(result.globalId);
244
+ toast.success(`Duplicated as #${result.expressId} (${direction}) — undo to remove`);
245
+ }
246
+ closeContextMenu();
247
+ },
248
+ [contextEntityRef, canEdit, duplicateEntity, setSelectedEntityId, closeContextMenu],
249
+ );
250
+
251
+ const handleDeleteEntity = useCallback(() => {
252
+ if (!contextEntityRef || !canEdit || !contextMenu.entityId) {
253
+ closeContextMenu();
254
+ return;
255
+ }
256
+ const ok = removeEntity(contextEntityRef.modelId, contextEntityRef.expressId);
257
+ if (ok) {
258
+ // Tombstoning only affects export — the rendered mesh is still
259
+ // in the GPU buffers. Hide it via the existing visibility system
260
+ // so the entity disappears from the scene and stops being
261
+ // pickable. `Show all` from the empty-space menu restores it
262
+ // (along with re-running undo to bring back the overlay).
263
+ hideEntity(contextMenu.entityId);
264
+ // Drop the selection so the right panel doesn't cling to a
265
+ // tombstoned id.
266
+ setSelectedEntityId(null);
267
+ toast.success(`${contextEntityType || 'Entity'} #${contextEntityRef.expressId} deleted — undo to restore`);
268
+ } else {
269
+ toast.error('Delete failed — entity not found in store overlay');
270
+ }
271
+ closeContextMenu();
272
+ }, [contextEntityRef, canEdit, contextEntityType, contextMenu.entityId, removeEntity, hideEntity, setSelectedEntityId, closeContextMenu]);
273
+
209
274
  if (!contextMenu.isOpen) {
210
275
  return null;
211
276
  }
@@ -257,6 +322,22 @@ export function EntityContextMenu() {
257
322
  <div className="h-px bg-border my-1" />
258
323
 
259
324
  <MenuItem icon={Copy} label="Copy GlobalId" onClick={handleCopyId} />
325
+
326
+ {/* Store-level mutations (bim.store.*). Only surfaced when there's
327
+ a live mutation view on the model — otherwise these would
328
+ silently no-op and confuse users. */}
329
+ {canEdit && (
330
+ <>
331
+ <div className="h-px bg-border my-1" />
332
+ <DuplicateRow onDuplicate={handleDuplicate} />
333
+ <MenuItem
334
+ icon={Trash2}
335
+ label="Delete entity"
336
+ tone="destructive"
337
+ onClick={handleDeleteEntity}
338
+ />
339
+ </>
340
+ )}
260
341
  </>
261
342
  )}
262
343
 
@@ -269,22 +350,105 @@ export function EntityContextMenu() {
269
350
  );
270
351
  }
271
352
 
353
+ type MenuItemTone = 'default' | 'destructive';
354
+
272
355
  interface MenuItemProps {
273
356
  icon: React.ComponentType<{ className?: string }>;
274
357
  label: string;
275
358
  onClick: () => void;
276
359
  disabled?: boolean;
360
+ /** Right-aligned keyboard hint (e.g. `'⌘D'`). */
361
+ shortcut?: string;
362
+ /**
363
+ * Visual tone:
364
+ * - `default` muted icon, neutral hover
365
+ * - `destructive` red-toned icon and red-tinted hover (Delete entity)
366
+ */
367
+ tone?: MenuItemTone;
277
368
  }
278
369
 
279
- function MenuItem({ icon: Icon, label, onClick, disabled }: MenuItemProps) {
370
+ /**
371
+ * Inline directional duplicate row — primary label on the left
372
+ * (clickable, fires the default +X duplicate), six axis chips on
373
+ * the right for explicit direction control. Mirrors the column
374
+ * placement axes the user already sees on the Raw STEP tab.
375
+ *
376
+ * Why six chips and not a sub-menu: a flyout for six options is
377
+ * wasted real estate, and the chip arrows let the user "see and
378
+ * pick" in one motion.
379
+ */
380
+ function DuplicateRow({ onDuplicate }: { onDuplicate: (dir: DuplicateDirection) => void }) {
381
+ return (
382
+ <div className="px-3 py-1.5 flex items-center gap-2 hover:bg-muted/40">
383
+ <button
384
+ type="button"
385
+ onClick={() => onDuplicate('+X')}
386
+ className="flex items-center gap-2 text-sm text-left flex-1 min-w-0 hover:text-foreground"
387
+ title="Duplicate one bbox-width along +X (default)"
388
+ >
389
+ <CopyPlus className="h-4 w-4 text-muted-foreground" />
390
+ <span>Duplicate</span>
391
+ <span className="ml-auto text-[10px] font-mono text-muted-foreground/70">⌘D</span>
392
+ </button>
393
+ <div className="flex items-center gap-0.5 shrink-0 border-l border-border/60 pl-2">
394
+ <DirectionChip dir="+X" label="→" tooltip="Duplicate +X (east)" onClick={() => onDuplicate('+X')} />
395
+ <DirectionChip dir="-X" label="←" tooltip="Duplicate −X (west)" onClick={() => onDuplicate('-X')} />
396
+ <DirectionChip dir="+Y" label="↗" tooltip="Duplicate +Y (north)" onClick={() => onDuplicate('+Y')} />
397
+ <DirectionChip dir="-Y" label="↙" tooltip="Duplicate −Y (south)" onClick={() => onDuplicate('-Y')} />
398
+ <DirectionChip dir="+Z" label="↑" tooltip="Duplicate +Z (up)" onClick={() => onDuplicate('+Z')} />
399
+ <DirectionChip dir="-Z" label="↓" tooltip="Duplicate −Z (down)" onClick={() => onDuplicate('-Z')} />
400
+ </div>
401
+ </div>
402
+ );
403
+ }
404
+
405
+ function DirectionChip({
406
+ dir,
407
+ label,
408
+ tooltip,
409
+ onClick,
410
+ }: {
411
+ dir: DuplicateDirection;
412
+ label: string;
413
+ tooltip: string;
414
+ onClick: () => void;
415
+ }) {
416
+ return (
417
+ <button
418
+ type="button"
419
+ onClick={onClick}
420
+ title={tooltip}
421
+ aria-label={tooltip}
422
+ className="h-5 w-5 flex items-center justify-center rounded text-[11px] font-mono leading-none text-muted-foreground hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:text-foreground transition-colors"
423
+ data-direction={dir}
424
+ >
425
+ {label}
426
+ </button>
427
+ );
428
+ }
429
+
430
+ function MenuItem({ icon: Icon, label, onClick, disabled, shortcut, tone = 'default' }: MenuItemProps) {
431
+ const iconClass =
432
+ tone === 'destructive'
433
+ ? 'h-4 w-4 text-red-500 dark:text-red-400'
434
+ : 'h-4 w-4 text-muted-foreground';
435
+ const hoverClass =
436
+ tone === 'destructive'
437
+ ? 'hover:bg-red-50 dark:hover:bg-red-950/40 hover:text-red-700 dark:hover:text-red-300'
438
+ : 'hover:bg-muted';
280
439
  return (
281
440
  <button
282
- className="w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
441
+ className={`w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed ${hoverClass}`}
283
442
  onClick={onClick}
284
443
  disabled={disabled}
285
444
  >
286
- <Icon className="h-4 w-4 text-muted-foreground" />
287
- <span>{label}</span>
445
+ <Icon className={iconClass} />
446
+ <span className="flex-1 min-w-0">{label}</span>
447
+ {shortcut && (
448
+ <span className="text-[10px] font-mono text-muted-foreground/70 shrink-0">
449
+ {shortcut}
450
+ </span>
451
+ )}
288
452
  </button>
289
453
  );
290
454
  }
@@ -12,13 +12,14 @@ import { Download, Loader2, Check, AlertCircle } from 'lucide-react';
12
12
  import { Button } from '@/components/ui/button';
13
13
  import { Badge } from '@/components/ui/badge';
14
14
  import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
15
- import { useViewerStore } from '@/store';
15
+ import { useViewerStore, countGeneratedTasks } from '@/store';
16
16
  import { configureMutationView } from '@/utils/configureMutationView';
17
17
  import { StepExporter } from '@ifc-lite/export';
18
18
  import { MutablePropertyView } from '@ifc-lite/mutations';
19
19
  import type { IfcDataStore } from '@ifc-lite/parser';
20
20
  import { toast } from '@/components/ui/toast';
21
21
  import { ensureModelExportReady } from '@/services/desktop-export';
22
+ import { spliceScheduleIntoExport } from '@/sdk/adapters/export-schedule-splice';
22
23
 
23
24
  interface ExportChangesButtonProps {
24
25
  /** Optional custom class name */
@@ -71,16 +72,25 @@ export function ExportChangesButton({ className }: ExportChangesButtonProps) {
71
72
  return null;
72
73
  }, [models, legacyIfcDataStore, legacyGeometryResult]);
73
74
 
74
- // Count mutations (includes georef mutations)
75
+ // Count mutations (includes georef mutations + pending generated schedule tasks)
75
76
  const mutationCount = useMemo(() => {
76
77
  if (!modelInfo) return 0;
77
78
  const mutationView = getMutationView(modelInfo.id);
78
79
  let count = mutationView?.getMutations().length || 0;
79
- const gm = useViewerStore.getState().georefMutations?.get(modelInfo.id);
80
+ const state = useViewerStore.getState();
81
+ const gm = state.georefMutations?.get(modelInfo.id);
80
82
  if (gm) {
81
83
  if (gm.projectedCRS) count += Object.keys(gm.projectedCRS).length;
82
84
  if (gm.mapConversion) count += Object.keys(gm.mapConversion).length;
83
85
  }
86
+ // Generated schedule tasks are first-class pending edits — they get
87
+ // spliced into the STEP on export (see injectScheduleIntoStep), so
88
+ // they belong in the same badge that tells users "you have unsaved
89
+ // work." Attribution: only count when this is the schedule's source
90
+ // model, so the badge doesn't inflate on every federated model.
91
+ if (state.scheduleSourceModelId === modelInfo.id) {
92
+ count += countGeneratedTasks(state.scheduleData);
93
+ }
84
94
  return count;
85
95
  // eslint-disable-next-line react-hooks/exhaustive-deps
86
96
  }, [modelInfo, getMutationView, mutationVersion]);
@@ -137,7 +147,8 @@ export function ExportChangesButton({ className }: ExportChangesButtonProps) {
137
147
  : 'IFC4';
138
148
 
139
149
  const exporter = new StepExporter(exportDataStore, mutationView || undefined);
140
- const georefMutations = useViewerStore.getState().georefMutations?.get(modelInfo.id) ?? undefined;
150
+ const state = useViewerStore.getState();
151
+ const georefMutations = state.georefMutations?.get(modelInfo.id) ?? undefined;
141
152
  const result = exporter.export({
142
153
  schema: schema as 'IFC2X3' | 'IFC4' | 'IFC4X3',
143
154
  includeGeometry: true,
@@ -148,8 +159,17 @@ export function ExportChangesButton({ className }: ExportChangesButtonProps) {
148
159
  application: 'ifc-lite',
149
160
  });
150
161
 
162
+ // Splice any pending schedule into the STEP via the shared
163
+ // helper. Same contract every export surface uses so bugs can't
164
+ // differ between the quick button, the dialog, and the SDK.
165
+ const spliced = spliceScheduleIntoExport(result, modelInfo.id, exportDataStore, {
166
+ scheduleData: state.scheduleData ?? null,
167
+ scheduleIsEdited: state.scheduleIsEdited === true,
168
+ scheduleSourceModelId: state.scheduleSourceModelId ?? null,
169
+ });
170
+
151
171
  // Download the file
152
- const blob = new Blob([toBlobPart(result.content)], { type: 'text/plain' });
172
+ const blob = new Blob([toBlobPart(spliced.content)], { type: 'text/plain' });
153
173
  const url = URL.createObjectURL(blob);
154
174
  const a = document.createElement('a');
155
175
  a.href = url;
@@ -56,6 +56,7 @@ import { ensureModelExportReady } from '@/services/desktop-export';
56
56
  import { StepExporter, MergedExporter, Ifc5Exporter, IFC5_KNOWN_PROP_NAMES, type MergeModelInput, type ExportProgress, type StepExportProgress } from '@ifc-lite/export';
57
57
  import { MutablePropertyView } from '@ifc-lite/mutations';
58
58
  import type { IfcDataStore } from '@ifc-lite/parser';
59
+ import { spliceScheduleIntoExport } from '@/sdk/adapters/export-schedule-splice';
59
60
 
60
61
  type ExportScope = 'single' | 'merged';
61
62
  type SchemaVersion = 'IFC2X3' | 'IFC4' | 'IFC4X3' | 'IFC5';
@@ -387,6 +388,12 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
387
388
  }
388
389
 
389
390
  if (!selectedModel) return;
391
+ // IFC5 export needs a parsed data store + geometry. Native-metadata
392
+ // models don't carry these, so bail with a descriptive error rather
393
+ // than passing nulls through.
394
+ if (!selectedModel.ifcDataStore) {
395
+ throw new Error('Selected model has no parsed IFC data store available for export');
396
+ }
390
397
  const mutationView = getMutationView(selectedModelId);
391
398
  const baseName = selectedModel.name.replace(/\.[^.]+$/, '');
392
399
 
@@ -515,7 +522,18 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
515
522
 
516
523
  setExportProgress(null);
517
524
 
518
- const blob = new Blob([toBlobPart(result.content)], { type: 'text/plain' });
525
+ // Splice pending schedule tasks into the STEP via the shared
526
+ // helper. Same contract every export surface uses so bugs
527
+ // can't differ between the dialog, the quick button, and the
528
+ // SDK adapter.
529
+ const state = useViewerStore.getState();
530
+ const spliced = spliceScheduleIntoExport(result, selectedModelId, selectedModel.ifcDataStore as IfcDataStore, {
531
+ scheduleData: state.scheduleData ?? null,
532
+ scheduleIsEdited: state.scheduleIsEdited === true,
533
+ scheduleSourceModelId: state.scheduleSourceModelId ?? null,
534
+ });
535
+
536
+ const blob = new Blob([toBlobPart(spliced.content)], { type: 'text/plain' });
519
537
  const url = URL.createObjectURL(blob);
520
538
  const a = document.createElement('a');
521
539
  a.href = url;