@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
@@ -9,19 +9,23 @@ import { TooltipProvider } from '@/components/ui/tooltip';
9
9
  import { MainToolbar } from './MainToolbar';
10
10
  import { HierarchyPanel } from './HierarchyPanel';
11
11
  import { PropertiesPanel } from './PropertiesPanel';
12
+ import { AddElementPanel } from './AddElementPanel';
12
13
  import { StatusBar } from './StatusBar';
13
14
  import { ViewportContainer } from './ViewportContainer';
14
15
  import { KeyboardShortcutsDialog, useKeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
15
16
  import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
16
17
  import { useViewerStore } from '@/store';
17
18
  import { EntityContextMenu } from './EntityContextMenu';
19
+ import { useDuplicateShortcut } from './useDuplicateShortcut';
18
20
  import { HoverTooltip } from './HoverTooltip';
19
21
  import { BCFPanel } from './BCFPanel';
20
22
  import { IDSPanel } from './IDSPanel';
21
23
  import { LensPanel } from './LensPanel';
22
24
  import { ListPanel } from './lists/ListPanel';
23
25
  import { ScriptPanel } from './ScriptPanel';
26
+ import { GanttPanel } from './schedule/GanttPanel';
24
27
  import { CommandPalette } from './CommandPalette';
28
+ import { SearchModal } from './SearchModal';
25
29
  import { DesktopEntitlementBanner } from './DesktopEntitlementBanner';
26
30
  import {
27
31
  closeActiveAnalysisExtension,
@@ -37,6 +41,8 @@ const BOTTOM_PANEL_MAX_RATIO = 0.7; // max 70% of container
37
41
  export function ViewerLayout() {
38
42
  // Initialize keyboard shortcuts
39
43
  useKeyboardShortcuts();
44
+ // ⌘D / Ctrl+D to duplicate the current selection.
45
+ useDuplicateShortcut();
40
46
  const shortcutsDialog = useKeyboardShortcutsDialog();
41
47
 
42
48
  // Command palette state
@@ -76,6 +82,8 @@ export function ViewerLayout() {
76
82
  const setRightPanelCollapsed = useViewerStore((s) => s.setRightPanelCollapsed);
77
83
  const bcfPanelVisible = useViewerStore((s) => s.bcfPanelVisible);
78
84
  const setBcfPanelVisible = useViewerStore((s) => s.setBcfPanelVisible);
85
+ const activeTool = useViewerStore((s) => s.activeTool);
86
+ const setActiveTool = useViewerStore((s) => s.setActiveTool);
79
87
  const idsPanelVisible = useViewerStore((s) => s.idsPanelVisible);
80
88
  const setIdsPanelVisible = useViewerStore((s) => s.setIdsPanelVisible);
81
89
  const listPanelVisible = useViewerStore((s) => s.listPanelVisible);
@@ -84,6 +92,8 @@ export function ViewerLayout() {
84
92
  const setLensPanelVisible = useViewerStore((s) => s.setLensPanelVisible);
85
93
  const scriptPanelVisible = useViewerStore((s) => s.scriptPanelVisible);
86
94
  const setScriptPanelVisible = useViewerStore((s) => s.setScriptPanelVisible);
95
+ const ganttPanelVisible = useViewerStore((s) => s.ganttPanelVisible);
96
+ const setGanttPanelVisible = useViewerStore((s) => s.setGanttPanelVisible);
87
97
  const analysisExtensionState = useSyncExternalStore(
88
98
  subscribeAnalysisExtensions,
89
99
  getAnalysisExtensionsSnapshot,
@@ -200,6 +210,7 @@ export function ViewerLayout() {
200
210
  <EntityContextMenu />
201
211
  <HoverTooltip />
202
212
  <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />
213
+ <SearchModal />
203
214
 
204
215
  {/* Main Toolbar */}
205
216
  <MainToolbar onShowShortcuts={shortcutsDialog.toggle} />
@@ -256,6 +267,8 @@ export function ViewerLayout() {
256
267
  <div className="h-full w-full overflow-hidden panel-container">
257
268
  {activeRightAnalysisExtension ? (
258
269
  activeRightAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
270
+ ) : activeTool === 'addElement' ? (
271
+ <AddElementPanel onClose={() => setActiveTool('select')} />
259
272
  ) : lensPanelVisible ? (
260
273
  <LensPanel onClose={() => setLensPanelVisible(false)} />
261
274
  ) : idsPanelVisible ? (
@@ -270,8 +283,8 @@ export function ViewerLayout() {
270
283
  </PanelGroup>
271
284
  </div>
272
285
 
273
- {/* Bottom Panel - Lists or Script (custom resizable, outside PanelGroup) */}
274
- {(listPanelVisible || scriptPanelVisible || !!activeBottomAnalysisExtension) && (
286
+ {/* Bottom Panel - Lists / Script / Gantt / analysis ext (custom resizable) */}
287
+ {(listPanelVisible || scriptPanelVisible || ganttPanelVisible || !!activeBottomAnalysisExtension) && (
275
288
  <div style={{ height: bottomHeight, flexShrink: 0 }} className="relative">
276
289
  {/* Drag handle */}
277
290
  <div
@@ -281,6 +294,8 @@ export function ViewerLayout() {
281
294
  <div className="h-full w-full overflow-hidden border-t pt-1.5">
282
295
  {activeBottomAnalysisExtension ? (
283
296
  activeBottomAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
297
+ ) : ganttPanelVisible ? (
298
+ <GanttPanel onClose={() => setGanttPanelVisible(false)} />
284
299
  ) : scriptPanelVisible ? (
285
300
  <ScriptPanel onClose={() => setScriptPanelVisible(false)} />
286
301
  ) : (
@@ -326,7 +341,7 @@ export function ViewerLayout() {
326
341
  <div className="absolute inset-x-0 bottom-0 h-[50vh] bg-background border-t rounded-t-xl shadow-xl z-40 animate-in slide-in-from-bottom">
327
342
  <div className="flex items-center justify-between p-2 border-b">
328
343
  <span className="font-medium text-sm">
329
- {activeAnalysisExtension ? activeAnalysisExtension.label : scriptPanelVisible ? 'Script' : listPanelVisible ? 'Lists' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : 'Properties'}
344
+ {activeAnalysisExtension ? activeAnalysisExtension.label : ganttPanelVisible ? 'Schedule' : scriptPanelVisible ? 'Script' : listPanelVisible ? 'Lists' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : 'Inspector'}
330
345
  </span>
331
346
  <button
332
347
  className="p-1 hover:bg-muted rounded"
@@ -334,6 +349,7 @@ export function ViewerLayout() {
334
349
  setRightPanelCollapsed(true);
335
350
  if (scriptPanelVisible) setScriptPanelVisible(false);
336
351
  if (listPanelVisible) setListPanelVisible(false);
352
+ if (ganttPanelVisible) setGanttPanelVisible(false);
337
353
  if (bcfPanelVisible) setBcfPanelVisible(false);
338
354
  if (lensPanelVisible) setLensPanelVisible(false);
339
355
  if (idsPanelVisible) setIdsPanelVisible(false);
@@ -351,10 +367,14 @@ export function ViewerLayout() {
351
367
  activeBottomAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
352
368
  ) : activeRightAnalysisExtension ? (
353
369
  activeRightAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
370
+ ) : ganttPanelVisible ? (
371
+ <GanttPanel onClose={() => setGanttPanelVisible(false)} />
354
372
  ) : scriptPanelVisible ? (
355
373
  <ScriptPanel onClose={() => setScriptPanelVisible(false)} />
356
374
  ) : listPanelVisible ? (
357
375
  <ListPanel onClose={() => setListPanelVisible(false)} />
376
+ ) : activeTool === 'addElement' ? (
377
+ <AddElementPanel onClose={() => setActiveTool('select')} />
358
378
  ) : lensPanelVisible ? (
359
379
  <LensPanel onClose={() => setLensPanelVisible(false)} />
360
380
  ) : idsPanelVisible ? (
@@ -386,7 +406,7 @@ export function ViewerLayout() {
386
406
  setRightPanelCollapsed(!rightPanelCollapsed);
387
407
  }}
388
408
  >
389
- Properties
409
+ Inspector
390
410
  </button>
391
411
  </div>
392
412
  </div>
@@ -456,11 +456,21 @@ export function Viewport({
456
456
  }
457
457
 
458
458
  // Set cursor based on active tool
459
- if (activeTool === 'measure') {
459
+ if (activeTool === 'measure' || activeTool === 'annotate' || activeTool === 'addElement') {
460
460
  canvas.style.cursor = 'crosshair';
461
461
  } else {
462
462
  canvas.style.cursor = 'default';
463
463
  }
464
+
465
+ // Clear add-element pending state + hover point when leaving the
466
+ // tool so the SVG overlay doesn't paint stale geometry from a
467
+ // previous session.
468
+ if (activeTool !== 'addElement') {
469
+ const state = useViewerStore.getState();
470
+ if (state.addElementPendingPoints.length > 0 || state.addElementHoverPoint !== null) {
471
+ state.clearAddElementPending();
472
+ }
473
+ }
464
474
  }, [activeTool, activeMeasurement, cancelMeasurement]);
465
475
 
466
476
  // Helper: calculate scale bar value (world-space size for 96px scale bar)
@@ -6,6 +6,7 @@ import { useMemo, useRef, useState, useCallback, useEffect, useSyncExternalStore
6
6
  import { Viewport } from './Viewport';
7
7
  import { ViewportOverlays } from './ViewportOverlays';
8
8
  import { ToolOverlays } from './ToolOverlays';
9
+ import { AnnotationLayer } from './annotations/AnnotationLayer';
9
10
  import { Section2DPanel } from './Section2DPanel';
10
11
  import { BasketPresentationDock } from './BasketPresentationDock';
11
12
  import { BCFOverlay } from './bcf/BCFOverlay';
@@ -851,6 +852,7 @@ export function ViewportContainer() {
851
852
  releaseGeometryAfterStream={false}
852
853
  onGeometryReleased={releaseGeometryMemory}
853
854
  />
855
+ <AnnotationLayer />
854
856
  {bcfOverlayVisible && <BCFOverlay />}
855
857
  <ViewportOverlays />
856
858
  <ToolOverlays />
@@ -0,0 +1,203 @@
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
+ * The inline note input that appears at the click site when the user
7
+ * drops a fresh pin with the Annotate tool. Shape mirrors the popover's
8
+ * edit mode so muscle memory carries over, but the chrome is lighter
9
+ * (a guiding label, no entity-context header) since this is a
10
+ * commit-or-cancel surface.
11
+ */
12
+
13
+ import { useCallback, useEffect, useRef, useState } from 'react';
14
+ import { Check, X } from 'lucide-react';
15
+ import { Button } from '@/components/ui/button';
16
+ import { cn } from '@/lib/utils';
17
+
18
+ const MAX_NOTE_LEN = 2000;
19
+ const SOFT_NOTE_LIMIT = 200;
20
+ const INPUT_WIDTH = 280;
21
+ const INPUT_OFFSET_X = 16;
22
+
23
+ export interface AnnotationDropInputProps {
24
+ anchorX: number;
25
+ anchorY: number;
26
+ canvasWidth: number;
27
+ canvasHeight: number;
28
+ /** Resolved entity type when the drop landed on a known mesh. */
29
+ entityType?: string | null;
30
+ entityExpressId?: number | null;
31
+ onSave: (note: string) => void;
32
+ onCancel: () => void;
33
+ }
34
+
35
+ export function AnnotationDropInput({
36
+ anchorX,
37
+ anchorY,
38
+ canvasWidth,
39
+ canvasHeight,
40
+ entityType,
41
+ entityExpressId,
42
+ onSave,
43
+ onCancel,
44
+ }: AnnotationDropInputProps) {
45
+ const [draft, setDraft] = useState('');
46
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
47
+ const containerRef = useRef<HTMLDivElement>(null);
48
+
49
+ useEffect(() => {
50
+ textareaRef.current?.focus();
51
+ }, []);
52
+
53
+ // Cancel on outside click, but defer registration so the click that
54
+ // dropped the pin doesn't immediately close the input.
55
+ useEffect(() => {
56
+ const handler = (e: MouseEvent) => {
57
+ const node = containerRef.current;
58
+ if (!node) return;
59
+ if (node.contains(e.target as Node)) return;
60
+ // Empty draft on outside-click → silent cancel; non-empty
61
+ // → commit the draft (matches "blur to save" feel without
62
+ // destroying typed content). An over-limit draft is rejected
63
+ // consistently with the disabled save button.
64
+ if (draft.trim().length === 0 || draft.length > MAX_NOTE_LEN) {
65
+ onCancel();
66
+ } else {
67
+ onSave(draft);
68
+ }
69
+ };
70
+ const id = window.setTimeout(() => {
71
+ document.addEventListener('mousedown', handler);
72
+ }, 0);
73
+ return () => {
74
+ window.clearTimeout(id);
75
+ document.removeEventListener('mousedown', handler);
76
+ };
77
+ }, [draft, onSave, onCancel]);
78
+
79
+ const handleKeyDown = useCallback(
80
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
81
+ if (e.key === 'Enter' && !e.shiftKey) {
82
+ e.preventDefault();
83
+ if (draft.trim().length === 0 || draft.length > MAX_NOTE_LEN) {
84
+ // Over-limit Enter does nothing — match the disabled button.
85
+ if (draft.trim().length === 0) onCancel();
86
+ } else {
87
+ onSave(draft);
88
+ }
89
+ } else if (e.key === 'Escape') {
90
+ e.preventDefault();
91
+ onCancel();
92
+ }
93
+ },
94
+ [draft, onSave, onCancel],
95
+ );
96
+
97
+ const wantsLeft = anchorX + INPUT_OFFSET_X + INPUT_WIDTH > canvasWidth;
98
+ const left = wantsLeft
99
+ ? Math.max(8, anchorX - INPUT_OFFSET_X - INPUT_WIDTH)
100
+ : Math.min(anchorX + INPUT_OFFSET_X, canvasWidth - INPUT_WIDTH - 8);
101
+ const top = Math.min(Math.max(8, anchorY - 8), canvasHeight - 140);
102
+
103
+ const charCountVisible = draft.length >= SOFT_NOTE_LIMIT;
104
+ const overSoftLimit = draft.length > SOFT_NOTE_LIMIT;
105
+ const overHardLimit = draft.length > MAX_NOTE_LEN;
106
+
107
+ return (
108
+ <div
109
+ ref={containerRef}
110
+ role="dialog"
111
+ aria-label="New annotation"
112
+ style={{ left, top, width: INPUT_WIDTH }}
113
+ className={cn(
114
+ 'absolute z-[60] pointer-events-auto',
115
+ 'rounded-md border border-amber-400/70 dark:border-amber-600/40',
116
+ 'bg-white/95 dark:bg-zinc-950/95 backdrop-blur-md',
117
+ 'shadow-[0_8px_32px_rgba(0,0,0,0.18)]',
118
+ 'overflow-hidden',
119
+ 'animate-in fade-in-0 zoom-in-95 duration-150',
120
+ )}
121
+ >
122
+ {/* Guiding label — explicit so the user knows what to type and
123
+ establishes "this is for capturing intent, not chat". */}
124
+ <div className="px-3 py-1.5 border-b border-zinc-200 dark:border-zinc-800 bg-amber-50/40 dark:bg-amber-950/20">
125
+ <span className="font-mono text-[10px] uppercase tracking-wider text-amber-700 dark:text-amber-300">
126
+ What's worth noting?
127
+ {entityType && (
128
+ <span className="ml-1.5 text-zinc-500 dark:text-zinc-400">
129
+ · {entityType}
130
+ {entityExpressId !== null && entityExpressId !== undefined && ` #${entityExpressId}`}
131
+ </span>
132
+ )}
133
+ </span>
134
+ </div>
135
+
136
+ <div className="px-3 py-2.5">
137
+ <textarea
138
+ ref={textareaRef}
139
+ value={draft}
140
+ onChange={(e) => setDraft(e.target.value)}
141
+ onKeyDown={handleKeyDown}
142
+ placeholder="A short note — flag a defect, ask a question, leave context…"
143
+ rows={3}
144
+ maxLength={MAX_NOTE_LEN + 100}
145
+ className={cn(
146
+ 'w-full resize-none font-mono text-[11px] leading-relaxed',
147
+ 'bg-zinc-50 dark:bg-zinc-900/60 text-zinc-800 dark:text-zinc-200',
148
+ 'border border-zinc-200 dark:border-zinc-800 rounded-sm',
149
+ 'px-2 py-1.5 outline-none focus:ring-1',
150
+ overHardLimit
151
+ ? 'focus:ring-red-400 border-red-300 dark:border-red-700/60'
152
+ : 'focus:ring-amber-400/50 focus:border-amber-300/60',
153
+ )}
154
+ spellCheck
155
+ autoCorrect="on"
156
+ />
157
+ <div className="mt-1.5 flex items-center justify-between gap-2 text-[10px] font-mono">
158
+ <span className="text-zinc-400 dark:text-zinc-500">
159
+ ⏎ save · ⇧⏎ newline · esc cancel
160
+ </span>
161
+ {charCountVisible && (
162
+ <span
163
+ className={cn(
164
+ 'tabular-nums',
165
+ overHardLimit
166
+ ? 'text-red-500'
167
+ : overSoftLimit
168
+ ? 'text-amber-600 dark:text-amber-400'
169
+ : 'text-zinc-400',
170
+ )}
171
+ >
172
+ {draft.length}/{MAX_NOTE_LEN}
173
+ </span>
174
+ )}
175
+ </div>
176
+ <div className="mt-2 flex items-center justify-end gap-1">
177
+ <Button
178
+ variant="ghost"
179
+ size="sm"
180
+ className="h-7 px-2 text-[11px]"
181
+ onClick={onCancel}
182
+ >
183
+ <X className="h-3 w-3 mr-1" />
184
+ Cancel
185
+ </Button>
186
+ <Button
187
+ size="sm"
188
+ className="h-7 px-2 text-[11px] bg-amber-500 hover:bg-amber-500/90 text-white"
189
+ onClick={() => {
190
+ if (overHardLimit) return;
191
+ if (draft.trim().length === 0) onCancel();
192
+ else onSave(draft);
193
+ }}
194
+ disabled={overHardLimit}
195
+ >
196
+ <Check className="h-3 w-3 mr-1" />
197
+ Drop pin
198
+ </Button>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ );
203
+ }
@@ -0,0 +1,287 @@
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
+ * DOM-billboard overlay for annotation pins.
7
+ *
8
+ * Sits on top of the WebGPU canvas and re-projects every pin's world
9
+ * position to screen space each frame via the camera callbacks.
10
+ * Uses a single rAF loop driven by camera/canvas events (we listen
11
+ * to a per-frame tick exposed by the camera) so the loop pauses when
12
+ * nothing's moving — the runtime cost when idle is zero.
13
+ *
14
+ * Key invariants:
15
+ * • The layer is `pointer-events: none` by default. Each pin and
16
+ * popover opts into `pointer-events: auto` so 3D interactions
17
+ * (orbit, pan, pick) still pass through the empty space between
18
+ * pins.
19
+ * • Only one popover or drop-input is visible at a time. They
20
+ * anchor to the pin's last projected position and re-anchor as
21
+ * the camera moves.
22
+ * • Persistence happens on commit/edit/delete via the slice's
23
+ * localStorage write — this layer never touches storage directly.
24
+ */
25
+
26
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
27
+ import { useViewerStore } from '@/store';
28
+ import { useIfc } from '@/hooks/useIfc';
29
+ import type { AnnotationPosition } from '@/store/slices/annotationsSlice';
30
+ import { AnnotationPin } from './AnnotationPin';
31
+ import { AnnotationPopover } from './AnnotationPopover';
32
+ import { AnnotationDropInput } from './AnnotationDropInput';
33
+
34
+ interface ProjectedPin {
35
+ id: string;
36
+ index: number;
37
+ /** Screen-space position relative to the canvas. Null when behind the camera. */
38
+ screen: { x: number; y: number } | null;
39
+ preview: string;
40
+ }
41
+
42
+ function makePreview(note: string, maxLen = 60): string {
43
+ const trimmed = note.trim();
44
+ if (trimmed.length === 0) return '(empty note)';
45
+ return trimmed.length > maxLen ? `${trimmed.slice(0, maxLen)}…` : trimmed;
46
+ }
47
+
48
+ /**
49
+ * Pins live in the canvas's coordinate space. The wrapping <div>
50
+ * matches the canvas's bounding rect; pins are positioned absolutely
51
+ * within it. We mirror the canvas geometry via a ResizeObserver +
52
+ * a per-frame projection tick.
53
+ */
54
+ export function AnnotationLayer() {
55
+ const annotations = useViewerStore((s) => s.annotations);
56
+ const draft = useViewerStore((s) => s.draft);
57
+ const selectedAnnotationId = useViewerStore((s) => s.selectedAnnotationId);
58
+ const selectAnnotation = useViewerStore((s) => s.selectAnnotation);
59
+ const updateAnnotation = useViewerStore((s) => s.updateAnnotation);
60
+ const removeAnnotation = useViewerStore((s) => s.removeAnnotation);
61
+ const commitDraft = useViewerStore((s) => s.commitDraft);
62
+ const cancelDraft = useViewerStore((s) => s.cancelDraft);
63
+ const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks);
64
+ const { ifcDataStore, models } = useIfc();
65
+
66
+ // Track canvas geometry so the overlay sits exactly on top.
67
+ const containerRef = useRef<HTMLDivElement>(null);
68
+ const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
69
+
70
+ useLayoutEffect(() => {
71
+ const container = containerRef.current;
72
+ if (!container) return;
73
+ const parent = container.parentElement;
74
+ if (!parent) return;
75
+
76
+ let observer: ResizeObserver | null = null;
77
+
78
+ const measure = (canvas: HTMLCanvasElement) => {
79
+ const rect = canvas.getBoundingClientRect();
80
+ setBounds({ width: rect.width, height: rect.height });
81
+ };
82
+
83
+ const bind = (canvas: HTMLCanvasElement) => {
84
+ measure(canvas);
85
+ observer = new ResizeObserver(() => measure(canvas));
86
+ observer.observe(canvas);
87
+ };
88
+
89
+ const initialCanvas = parent.querySelector('canvas') as HTMLCanvasElement | null;
90
+ if (initialCanvas) {
91
+ bind(initialCanvas);
92
+ return () => observer?.disconnect();
93
+ }
94
+
95
+ // Canvas not mounted yet (initial mount before viewport renders) —
96
+ // watch the parent for the canvas to appear, then bind once it does.
97
+ const mutationObserver = new MutationObserver(() => {
98
+ const canvas = parent.querySelector('canvas') as HTMLCanvasElement | null;
99
+ if (canvas) {
100
+ bind(canvas);
101
+ mutationObserver.disconnect();
102
+ }
103
+ });
104
+ mutationObserver.observe(parent, { childList: true, subtree: true });
105
+
106
+ return () => {
107
+ mutationObserver.disconnect();
108
+ observer?.disconnect();
109
+ };
110
+ }, []);
111
+
112
+ // Stable list view so React doesn't churn when the Map identity
113
+ // changes but the entries are equal.
114
+ const annotationList = useMemo(() => Array.from(annotations.values()), [annotations]);
115
+
116
+ // Per-frame projection tick. We don't have a global "camera moved"
117
+ // event, so a rAF loop is the cheapest way to keep pins glued to
118
+ // the world. The loop is mostly idle — projection is < 10 µs per
119
+ // pin and the typical scene has < 20 pins.
120
+ const [projectedPins, setProjectedPins] = useState<ProjectedPin[]>([]);
121
+ const [draftScreen, setDraftScreen] = useState<{ x: number; y: number } | null>(null);
122
+
123
+ useEffect(() => {
124
+ const project = cameraCallbacks.projectToScreen;
125
+ if (!project) {
126
+ setProjectedPins([]);
127
+ setDraftScreen(null);
128
+ return;
129
+ }
130
+
131
+ let raf: number | null = null;
132
+ let lastSerialized = '';
133
+
134
+ const tick = () => {
135
+ const next: ProjectedPin[] = annotationList.map((ann, i) => ({
136
+ id: ann.id,
137
+ index: i + 1,
138
+ screen: project(ann.position),
139
+ preview: makePreview(ann.note),
140
+ }));
141
+
142
+ // Cheap deep-eq check: serialize the screen positions. Skip the
143
+ // setState when nothing moved, otherwise we re-render every
144
+ // frame even when the camera is still.
145
+ const serialized = next.map((p) => `${p.id}:${p.screen?.x ?? 'x'}:${p.screen?.y ?? 'y'}`).join(',');
146
+ if (serialized !== lastSerialized) {
147
+ lastSerialized = serialized;
148
+ setProjectedPins(next);
149
+ }
150
+
151
+ const draftPos = useViewerStore.getState().draft?.position ?? null;
152
+ const draftScreenNext = draftPos ? project(draftPos) : null;
153
+ setDraftScreen((prev) => {
154
+ if (prev === draftScreenNext) return prev;
155
+ if (prev && draftScreenNext && prev.x === draftScreenNext.x && prev.y === draftScreenNext.y) {
156
+ return prev;
157
+ }
158
+ return draftScreenNext;
159
+ });
160
+
161
+ raf = requestAnimationFrame(tick);
162
+ };
163
+
164
+ raf = requestAnimationFrame(tick);
165
+ return () => {
166
+ if (raf !== null) cancelAnimationFrame(raf);
167
+ };
168
+ // The list of annotations is captured per-render via annotationList;
169
+ // that closure is what the rAF tick reads. Pin position changes
170
+ // automatically pick up via the next render's loop replacement.
171
+ }, [cameraCallbacks, annotationList]);
172
+
173
+ const selectedAnnotation = selectedAnnotationId ? annotations.get(selectedAnnotationId) : null;
174
+ const selectedScreen = useMemo(() => {
175
+ if (!selectedAnnotation) return null;
176
+ return projectedPins.find((p) => p.id === selectedAnnotation.id)?.screen ?? null;
177
+ }, [selectedAnnotation, projectedPins]);
178
+
179
+ // Resolve entity type + id for the popover header. Cheap lookup
180
+ // against whichever data store the annotation was anchored to.
181
+ const resolveEntityType = (modelId: string | null, expressId: number | null): string | null => {
182
+ if (expressId === null) return null;
183
+ // Federation safety: when the annotation carries a modelId that
184
+ // isn't in the current `models` map, falling back to
185
+ // `ifcDataStore` would silently resolve `expressId` against the
186
+ // wrong model (the same id can exist in many federated models).
187
+ // The fallback is therefore restricted to single-model sessions.
188
+ let dataStore: typeof ifcDataStore | null;
189
+ if (!modelId) {
190
+ dataStore = ifcDataStore;
191
+ } else {
192
+ const scoped = models.get(modelId)?.ifcDataStore;
193
+ if (scoped) {
194
+ dataStore = scoped;
195
+ } else if (models.size <= 1) {
196
+ dataStore = ifcDataStore;
197
+ } else {
198
+ return null;
199
+ }
200
+ }
201
+ if (!dataStore?.entities) return null;
202
+ return dataStore.entities.getTypeName(expressId) || null;
203
+ };
204
+
205
+ if (!bounds) {
206
+ return <div ref={containerRef} className="absolute inset-0 pointer-events-none" />;
207
+ }
208
+
209
+ return (
210
+ <div
211
+ ref={containerRef}
212
+ className="absolute inset-0 pointer-events-none overflow-hidden"
213
+ aria-label="Annotations layer"
214
+ >
215
+ {/* Pins */}
216
+ {projectedPins.map((pin) => {
217
+ if (!pin.screen) return null;
218
+ const annotation = annotations.get(pin.id);
219
+ if (!annotation) return null;
220
+ const isSelected = selectedAnnotationId === pin.id;
221
+ return (
222
+ <div
223
+ key={pin.id}
224
+ data-annotation-pin-id={pin.id}
225
+ className="absolute pointer-events-auto"
226
+ style={{
227
+ left: pin.screen.x,
228
+ top: pin.screen.y,
229
+ transform: 'translate(-50%, -50%)',
230
+ animationDelay: `${pin.index * 40}ms`,
231
+ }}
232
+ >
233
+ <AnnotationPin
234
+ index={pin.index}
235
+ selected={isSelected}
236
+ preview={pin.preview}
237
+ onClick={() => selectAnnotation(isSelected ? null : pin.id)}
238
+ />
239
+ </div>
240
+ );
241
+ })}
242
+
243
+ {/* Popover for the selected pin */}
244
+ {selectedAnnotation && selectedScreen && (
245
+ <AnnotationPopover
246
+ annotation={selectedAnnotation}
247
+ anchorX={selectedScreen.x}
248
+ anchorY={selectedScreen.y}
249
+ canvasWidth={bounds.width}
250
+ canvasHeight={bounds.height}
251
+ entityType={resolveEntityType(selectedAnnotation.modelId, selectedAnnotation.entityExpressId)}
252
+ onSave={(note) => updateAnnotation(selectedAnnotation.id, note)}
253
+ onDelete={() => removeAnnotation(selectedAnnotation.id)}
254
+ onClose={() => selectAnnotation(null)}
255
+ />
256
+ )}
257
+
258
+ {/* Drop input + ghost pin while drafting */}
259
+ {draft && draftScreen && (
260
+ <>
261
+ <div
262
+ className="absolute pointer-events-none"
263
+ style={{
264
+ left: draftScreen.x,
265
+ top: draftScreen.y,
266
+ transform: 'translate(-50%, -50%)',
267
+ }}
268
+ >
269
+ <AnnotationPin index={annotationList.length + 1} variant="draft" />
270
+ </div>
271
+ <AnnotationDropInput
272
+ anchorX={draftScreen.x}
273
+ anchorY={draftScreen.y}
274
+ canvasWidth={bounds.width}
275
+ canvasHeight={bounds.height}
276
+ entityType={resolveEntityType(draft.modelId, draft.entityExpressId)}
277
+ entityExpressId={draft.entityExpressId}
278
+ onSave={(note) => commitDraft(note)}
279
+ onCancel={cancelDraft}
280
+ />
281
+ </>
282
+ )}
283
+ </div>
284
+ );
285
+ }
286
+
287
+ export type { AnnotationPosition };