@ifc-lite/viewer 1.17.4 → 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 (196) hide show
  1. package/.turbo/turbo-build.log +20 -17
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +630 -0
  4. package/DESKTOP_CONTRACT_VERSION +1 -1
  5. package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  6. package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
  7. package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
  8. package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
  9. package/dist/assets/ids-DQ5jY0E8.js +1 -0
  10. package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
  11. package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
  12. package/dist/assets/index-COnQRuqY.css +1 -0
  13. package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
  14. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  15. package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
  16. package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
  17. package/dist/index.html +8 -7
  18. package/index.html +1 -0
  19. package/package.json +13 -13
  20. package/src/App.tsx +16 -2
  21. package/src/apache-arrow.d.ts +30 -0
  22. package/src/components/viewer/AddElementPanel.tsx +758 -0
  23. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  24. package/src/components/viewer/CesiumOverlay.tsx +62 -19
  25. package/src/components/viewer/ChatPanel.tsx +259 -93
  26. package/src/components/viewer/CommandPalette.tsx +56 -7
  27. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  28. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  29. package/src/components/viewer/ExportDialog.tsx +19 -1
  30. package/src/components/viewer/MainToolbar.tsx +73 -13
  31. package/src/components/viewer/PropertiesPanel.tsx +237 -23
  32. package/src/components/viewer/SearchInline.tsx +669 -0
  33. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  34. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  35. package/src/components/viewer/SearchModal.text.tsx +388 -0
  36. package/src/components/viewer/SearchModal.tsx +235 -0
  37. package/src/components/viewer/SettingsPage.tsx +252 -101
  38. package/src/components/viewer/ThemeSwitch.tsx +63 -7
  39. package/src/components/viewer/ToolOverlays.tsx +5 -0
  40. package/src/components/viewer/ViewerLayout.tsx +25 -4
  41. package/src/components/viewer/Viewport.tsx +25 -3
  42. package/src/components/viewer/ViewportContainer.tsx +51 -64
  43. package/src/components/viewer/ViewportOverlays.tsx +5 -2
  44. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  45. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  46. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  47. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  48. package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
  49. package/src/components/viewer/chat/ModelSelector.tsx +90 -54
  50. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
  52. package/src/components/viewer/properties/LocationMap.tsx +9 -7
  53. package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
  54. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  55. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  56. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  57. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  58. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  59. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  60. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  61. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  62. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  63. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  64. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  65. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  66. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  67. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  68. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  69. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  70. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  71. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  72. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  73. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  74. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  75. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  76. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  77. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  78. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  79. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  80. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  81. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  82. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  83. package/src/components/viewer/selectionHandlers.ts +446 -0
  84. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  85. package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
  86. package/src/components/viewer/tools/SectionPanel.tsx +39 -18
  87. package/src/components/viewer/useAnimationLoop.ts +9 -1
  88. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  89. package/src/components/viewer/useMouseControls.ts +9 -1
  90. package/src/components/viewer/useRenderUpdates.ts +1 -1
  91. package/src/hooks/ids/idsDataAccessor.ts +60 -24
  92. package/src/hooks/ingest/viewerModelIngest.ts +7 -2
  93. package/src/hooks/useIfcFederation.ts +326 -71
  94. package/src/hooks/useIfcLoader.ts +23 -10
  95. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  96. package/src/hooks/useSandbox.ts +1 -1
  97. package/src/hooks/useSearchIndex.ts +125 -0
  98. package/src/hooks/useViewControls.ts +13 -5
  99. package/src/index.css +550 -10
  100. package/src/lib/desktop-entitlement.ts +2 -4
  101. package/src/lib/geo/cesium-bridge.ts +15 -7
  102. package/src/lib/geo/effective-georef.test.ts +73 -0
  103. package/src/lib/geo/effective-georef.ts +111 -0
  104. package/src/lib/geo/reproject.ts +105 -19
  105. package/src/lib/llm/byok-guard.test.ts +77 -0
  106. package/src/lib/llm/byok-guard.ts +39 -0
  107. package/src/lib/llm/free-models.test.ts +0 -6
  108. package/src/lib/llm/models.ts +104 -42
  109. package/src/lib/llm/stream-client.ts +74 -110
  110. package/src/lib/llm/stream-direct.test.ts +130 -0
  111. package/src/lib/llm/stream-direct.ts +316 -0
  112. package/src/lib/llm/system-prompt.test.ts +14 -0
  113. package/src/lib/llm/system-prompt.ts +102 -1
  114. package/src/lib/llm/types.ts +20 -2
  115. package/src/lib/recent-files.ts +38 -4
  116. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  117. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  118. package/src/lib/scripts/templates.ts +7 -0
  119. package/src/lib/search/common-ifc-types.ts +36 -0
  120. package/src/lib/search/filter-evaluate.test.ts +537 -0
  121. package/src/lib/search/filter-evaluate.ts +610 -0
  122. package/src/lib/search/filter-rules.test.ts +119 -0
  123. package/src/lib/search/filter-rules.ts +198 -0
  124. package/src/lib/search/filter-schema.test.ts +233 -0
  125. package/src/lib/search/filter-schema.ts +146 -0
  126. package/src/lib/search/recent-searches.test.ts +116 -0
  127. package/src/lib/search/recent-searches.ts +93 -0
  128. package/src/lib/search/result-export.test.ts +101 -0
  129. package/src/lib/search/result-export.ts +104 -0
  130. package/src/lib/search/saved-filters.test.ts +118 -0
  131. package/src/lib/search/saved-filters.ts +154 -0
  132. package/src/lib/search/tier0-scan.test.ts +196 -0
  133. package/src/lib/search/tier0-scan.ts +237 -0
  134. package/src/lib/search/tier1-index.test.ts +242 -0
  135. package/src/lib/search/tier1-index.ts +448 -0
  136. package/src/main.tsx +1 -10
  137. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  138. package/src/sdk/adapters/export-adapter.ts +404 -1
  139. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  140. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  141. package/src/sdk/adapters/model-compat.ts +8 -2
  142. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  143. package/src/sdk/adapters/store-adapter.ts +201 -0
  144. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  145. package/src/sdk/local-backend.ts +16 -8
  146. package/src/services/api-keys.ts +73 -0
  147. package/src/services/desktop-export.ts +3 -1
  148. package/src/services/desktop-native-metadata.ts +41 -18
  149. package/src/services/file-dialog.ts +4 -1
  150. package/src/services/tauri-modules.d.ts +25 -0
  151. package/src/store/basketVisibleSet.ts +3 -0
  152. package/src/store/constants.ts +20 -2
  153. package/src/store/globalId.ts +4 -1
  154. package/src/store/index.ts +82 -6
  155. package/src/store/slices/addElementMeshes.ts +365 -0
  156. package/src/store/slices/addElementSlice.ts +275 -0
  157. package/src/store/slices/annotationsSlice.test.ts +133 -0
  158. package/src/store/slices/annotationsSlice.ts +251 -0
  159. package/src/store/slices/cesiumSlice.ts +5 -0
  160. package/src/store/slices/chatSlice.test.ts +6 -76
  161. package/src/store/slices/chatSlice.ts +17 -58
  162. package/src/store/slices/dataSlice.test.ts +23 -4
  163. package/src/store/slices/dataSlice.ts +1 -1
  164. package/src/store/slices/modelSlice.test.ts +67 -9
  165. package/src/store/slices/modelSlice.ts +39 -7
  166. package/src/store/slices/mutationSlice.ts +964 -3
  167. package/src/store/slices/overlayCompositor.test.ts +164 -0
  168. package/src/store/slices/overlaySlice.test.ts +93 -0
  169. package/src/store/slices/overlaySlice.ts +151 -0
  170. package/src/store/slices/pinboardSlice.test.ts +6 -1
  171. package/src/store/slices/playbackSlice.ts +128 -0
  172. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  173. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  174. package/src/store/slices/scheduleSlice.test.ts +694 -0
  175. package/src/store/slices/scheduleSlice.ts +1330 -0
  176. package/src/store/slices/searchSlice.test.ts +342 -0
  177. package/src/store/slices/searchSlice.ts +341 -0
  178. package/src/store/slices/sectionSlice.test.ts +87 -7
  179. package/src/store/slices/sectionSlice.ts +151 -5
  180. package/src/store/slices/selectionSlice.test.ts +46 -0
  181. package/src/store/slices/selectionSlice.ts +20 -0
  182. package/src/store/slices/uiSlice.ts +28 -5
  183. package/src/store/types.ts +26 -0
  184. package/src/store.ts +14 -0
  185. package/src/utils/nativeSpatialDataStore.ts +4 -1
  186. package/src/utils/viewportUtils.ts +7 -2
  187. package/src/vite-env.d.ts +0 -4
  188. package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
  189. package/dist/assets/ids-B4jTqB1O.js +0 -1
  190. package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
  191. package/dist/assets/index-DckuDqlv.css +0 -1
  192. package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
  193. package/src/components/viewer/UpgradePage.tsx +0 -71
  194. package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
  195. package/src/lib/llm/ClerkChatSync.tsx +0 -74
  196. package/src/lib/llm/clerk-auth.ts +0 -62
@@ -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;
@@ -10,6 +10,7 @@ import {
10
10
  PersonStanding,
11
11
  Ruler,
12
12
  Scissors,
13
+ MapPin,
13
14
  Eye,
14
15
  EyeOff,
15
16
  Equal,
@@ -37,6 +38,7 @@ import {
37
38
  Layout,
38
39
  LayoutTemplate,
39
40
  FileCode2,
41
+ CalendarClock,
40
42
  Globe2,
41
43
  Settings,
42
44
  } from 'lucide-react';
@@ -66,6 +68,7 @@ import { ExportDialog } from './ExportDialog';
66
68
  import { BulkPropertyEditor } from './BulkPropertyEditor';
67
69
  import { DataConnector } from './DataConnector';
68
70
  import { ExportChangesButton } from './ExportChangesButton';
71
+ import { SearchInline } from './SearchInline';
69
72
  // CesiumSettingsDialog removed — settings now shown as overlay on Cesium viewer
70
73
  import { useFloorplanView } from '@/hooks/useFloorplanView';
71
74
  import { buildDesktopUpgradeUrl, hasDesktopFeatureAccess, type DesktopFeature } from '@/lib/desktop-product';
@@ -84,7 +87,7 @@ import {
84
87
  subscribeAnalysisExtensions,
85
88
  } from '@/services/analysis-extensions';
86
89
 
87
- type Tool = 'select' | 'walk' | 'measure' | 'section';
90
+ type Tool = 'select' | 'walk' | 'measure' | 'section' | 'annotate';
88
91
  type WorkspacePanel = 'script' | 'list' | 'bcf' | 'ids' | 'lens' | string;
89
92
 
90
93
  function isNativeFileHandle(file: File | NativeFileHandle): file is NativeFileHandle {
@@ -100,21 +103,39 @@ interface ToolButtonProps {
100
103
  shortcut?: string;
101
104
  activeTool: string;
102
105
  onToolChange: (tool: Tool) => void;
106
+ /**
107
+ * Tailwind classes applied when this tool is active. Defaults to the
108
+ * shared `bg-primary text-primary-foreground` shape; pass a per-tool
109
+ * accent (e.g. amber for Annotate) to set tools apart visually
110
+ * without breaking the toolbar's tool-button rhythm.
111
+ */
112
+ activeAccentClass?: string;
103
113
  }
104
114
 
105
- function ToolButton({ tool, icon: Icon, label, shortcut, activeTool, onToolChange }: ToolButtonProps) {
115
+ function ToolButton({
116
+ tool,
117
+ icon: Icon,
118
+ label,
119
+ shortcut,
120
+ activeTool,
121
+ onToolChange,
122
+ activeAccentClass,
123
+ }: ToolButtonProps) {
124
+ const isActive = activeTool === tool;
106
125
  return (
107
126
  <Tooltip>
108
127
  <TooltipTrigger asChild>
109
128
  <Button
110
- variant={activeTool === tool ? 'default' : 'ghost'}
129
+ variant={isActive ? 'default' : 'ghost'}
111
130
  size="icon-sm"
112
131
  onClick={(e) => {
113
132
  // Blur button to close tooltip after click
114
133
  (e.currentTarget as HTMLButtonElement).blur();
115
134
  onToolChange(tool);
116
135
  }}
117
- className={cn(activeTool === tool && 'bg-primary text-primary-foreground')}
136
+ className={cn(
137
+ isActive && (activeAccentClass ?? 'bg-primary text-primary-foreground'),
138
+ )}
118
139
  >
119
140
  <Icon className="h-4 w-4" />
120
141
  </Button>
@@ -305,7 +326,10 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
305
326
  const setLensPanelVisible = useViewerStore((state) => state.setLensPanelVisible);
306
327
  const scriptPanelVisible = useViewerStore((state) => state.scriptPanelVisible);
307
328
  const setScriptPanelVisible = useViewerStore((state) => state.setScriptPanelVisible);
329
+ const ganttPanelVisible = useViewerStore((state) => state.ganttPanelVisible);
330
+ const setGanttPanelVisible = useViewerStore((state) => state.setGanttPanelVisible);
308
331
  // Cesium 3D overlay state
332
+ const cesiumAvailable = useViewerStore((state) => state.cesiumAvailable);
309
333
  const cesiumEnabled = useViewerStore((state) => state.cesiumEnabled);
310
334
  const toggleCesium = useViewerStore((state) => state.toggleCesium);
311
335
  const storeModels = useViewerStore((state) => state.models);
@@ -507,21 +531,31 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
507
531
  return false;
508
532
  }, [desktopEntitlement, promptDesktopUpgrade]);
509
533
 
510
- const handleToggleBottomPanel = useCallback((panel: 'script' | 'list') => {
534
+ const handleToggleBottomPanel = useCallback((panel: 'script' | 'list' | 'gantt') => {
511
535
  if (activeAnalysisExtension?.placement === 'bottom') {
512
536
  closeActiveAnalysisExtension();
513
537
  }
514
- const isScriptPanel = panel === 'script';
515
- const nextScriptVisible = isScriptPanel ? !scriptPanelVisible : false;
516
- const nextListVisible = isScriptPanel ? false : !listPanelVisible;
538
+ const nextScriptVisible = panel === 'script' ? !scriptPanelVisible : false;
539
+ const nextListVisible = panel === 'list' ? !listPanelVisible : false;
540
+ const nextGanttVisible = panel === 'gantt' ? !ganttPanelVisible : false;
517
541
 
518
542
  setScriptPanelVisible(nextScriptVisible);
519
543
  setListPanelVisible(nextListVisible);
544
+ setGanttPanelVisible(nextGanttVisible);
520
545
 
521
- if (nextScriptVisible || nextListVisible) {
546
+ if (nextScriptVisible || nextListVisible || nextGanttVisible) {
522
547
  setRightPanelCollapsed(false);
523
548
  }
524
- }, [activeAnalysisExtension?.placement, listPanelVisible, scriptPanelVisible, setListPanelVisible, setRightPanelCollapsed, setScriptPanelVisible]);
549
+ }, [
550
+ activeAnalysisExtension?.placement,
551
+ ganttPanelVisible,
552
+ listPanelVisible,
553
+ scriptPanelVisible,
554
+ setGanttPanelVisible,
555
+ setListPanelVisible,
556
+ setRightPanelCollapsed,
557
+ setScriptPanelVisible,
558
+ ]);
525
559
 
526
560
  const handleToggleRightPanel = useCallback((panel: 'bcf' | 'ids' | 'lens') => {
527
561
  if (activeAnalysisExtension?.placement !== 'bottom') {
@@ -576,6 +610,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
576
610
  if ((extension.placement ?? 'right') === 'bottom') {
577
611
  setScriptPanelVisible(false);
578
612
  setListPanelVisible(false);
613
+ setGanttPanelVisible(false);
579
614
  setRightPanelCollapsed(false);
580
615
  return;
581
616
  }
@@ -588,6 +623,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
588
623
  analysisExtensionState.activeId,
589
624
  analysisExtensionState.extensions,
590
625
  setBcfPanelVisible,
626
+ setGanttPanelVisible,
591
627
  setIdsPanelVisible,
592
628
  setLensPanelVisible,
593
629
  setListPanelVisible,
@@ -599,6 +635,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
599
635
  const panels = new Set<WorkspacePanel>();
600
636
  if (scriptPanelVisible) panels.add('script');
601
637
  if (listPanelVisible) panels.add('list');
638
+ if (ganttPanelVisible) panels.add('gantt');
602
639
  if (bcfPanelVisible) panels.add('bcf');
603
640
  if (idsPanelVisible) panels.add('ids');
604
641
  if (lensPanelVisible) panels.add('lens');
@@ -607,6 +644,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
607
644
  }, [
608
645
  analysisExtensionState.activeId,
609
646
  bcfPanelVisible,
647
+ ganttPanelVisible,
610
648
  idsPanelVisible,
611
649
  lensPanelVisible,
612
650
  listPanelVisible,
@@ -618,6 +656,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
618
656
  if (activeWorkspacePanels.size > 1) return 'Multiple Panels';
619
657
  if (activeWorkspacePanels.has('script')) return 'Script Editor';
620
658
  if (activeWorkspacePanels.has('list')) return 'Lists';
659
+ if (activeWorkspacePanels.has('gantt')) return 'Schedule';
621
660
  if (activeWorkspacePanels.has('bcf')) return 'BCF Issues';
622
661
  if (activeWorkspacePanels.has('ids')) return 'IDS Validation';
623
662
  if (activeWorkspacePanels.has('lens')) return 'Lens Rules';
@@ -947,6 +986,13 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
947
986
  <FileSpreadsheet className="h-4 w-4 mr-2" />
948
987
  Lists
949
988
  </DropdownMenuCheckboxItem>
989
+ <DropdownMenuCheckboxItem
990
+ checked={activeWorkspacePanels.has('gantt')}
991
+ onCheckedChange={() => handleToggleBottomPanel('gantt')}
992
+ >
993
+ <CalendarClock className="h-4 w-4 mr-2" />
994
+ Schedule (Gantt)
995
+ </DropdownMenuCheckboxItem>
950
996
  <DropdownMenuSeparator />
951
997
  <DropdownMenuCheckboxItem
952
998
  checked={activeWorkspacePanels.has('bcf')}
@@ -1010,6 +1056,11 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1010
1056
 
1011
1057
  <Separator orientation="vertical" className="h-6 mx-1" />
1012
1058
 
1059
+ {/* ── Search (Tier-0 inline; ⌘F or / to focus) ── */}
1060
+ <SearchInline />
1061
+
1062
+ <Separator orientation="vertical" className="h-6 mx-1" />
1063
+
1013
1064
  {/* ── Navigation Tools ── */}
1014
1065
  <ToolButton tool="select" icon={MousePointer2} label="Select" shortcut="V" activeTool={activeTool} onToolChange={setActiveTool} />
1015
1066
  <ToolButton tool="walk" icon={PersonStanding} label="Walk Mode" shortcut="C" activeTool={activeTool} onToolChange={setActiveTool} />
@@ -1019,6 +1070,15 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1019
1070
  {/* ── Measurement & Section ── */}
1020
1071
  <ToolButton tool="measure" icon={Ruler} label="Measure" shortcut="M" activeTool={activeTool} onToolChange={setActiveTool} />
1021
1072
  <ToolButton tool="section" icon={Scissors} label="Section" shortcut="X" activeTool={activeTool} onToolChange={setActiveTool} />
1073
+ <ToolButton
1074
+ tool="annotate"
1075
+ icon={MapPin}
1076
+ label="Annotate"
1077
+ shortcut="P"
1078
+ activeTool={activeTool}
1079
+ onToolChange={setActiveTool}
1080
+ activeAccentClass="bg-amber-500 text-white hover:bg-amber-500/90"
1081
+ />
1022
1082
 
1023
1083
  {/* Floorplan dropdown */}
1024
1084
  {availableStoreys.length > 0 && (
@@ -1157,8 +1217,8 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1157
1217
  </TooltipContent>
1158
1218
  </Tooltip>
1159
1219
 
1160
- {/* Cesium 3D Context toggle + settings */}
1161
- {hasModelsLoaded && (
1220
+ {/* Cesium 3D Context toggle + settings — web only, only when model has georeferencing */}
1221
+ {cesiumAvailable && !desktopShell && (
1162
1222
  <div className="flex items-center">
1163
1223
  <Tooltip>
1164
1224
  <TooltipTrigger asChild>
@@ -1293,7 +1353,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1293
1353
  <ThemeSwitch />
1294
1354
  </div>
1295
1355
  </TooltipTrigger>
1296
- <TooltipContent>Toggle theme</TooltipContent>
1356
+ <TooltipContent>Toggle theme (Shift+click for secret mode)</TooltipContent>
1297
1357
  </Tooltip>
1298
1358
 
1299
1359
  <Tooltip>