@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
@@ -0,0 +1,90 @@
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
+ * Single annotation pin — a 14px circle anchored to a screen point.
7
+ *
8
+ * The dot itself is small; we wrap it in a 24×24 invisible hit-target
9
+ * so it stays comfortable on touch and doesn't fight pointer events
10
+ * on the surrounding canvas. The pin sits in the canvas overlay layer
11
+ * (`AnnotationLayer`) which positions it absolutely each frame.
12
+ */
13
+
14
+ import { forwardRef } from 'react';
15
+ import { cn } from '@/lib/utils';
16
+
17
+ export interface AnnotationPinProps {
18
+ /** Index in the rendered list — 1-based. Shown inside the dot when ≤ 9. */
19
+ index: number;
20
+ /** Highlights the dot with the emerald ring used for selection across the viewer. */
21
+ selected?: boolean;
22
+ /** Tooltip preview when the user hovers (author + first ~40 chars of note). */
23
+ preview?: string;
24
+ /** Called when the dot is clicked. */
25
+ onClick?: () => void;
26
+ /** Called when the dot is right-clicked — used by the layer for "delete via menu". */
27
+ onContextMenu?: (e: React.MouseEvent) => void;
28
+ /** Visual variant. `draft` is a slightly washed-out pin used while the note input is open. */
29
+ variant?: 'idle' | 'draft';
30
+ }
31
+
32
+ export const AnnotationPin = forwardRef<HTMLButtonElement, AnnotationPinProps>(
33
+ function AnnotationPin({ index, selected, preview, onClick, onContextMenu, variant = 'idle' }, ref) {
34
+ // 1-character glyph: the index for ≤ 9, ellipsis otherwise. Keeps
35
+ // the pin readable at 14px without feeling crowded.
36
+ const glyph = index <= 9 ? String(index) : '·';
37
+
38
+ return (
39
+ <button
40
+ ref={ref}
41
+ type="button"
42
+ title={preview}
43
+ aria-label={preview ? `Annotation ${index}: ${preview}` : `Annotation ${index}`}
44
+ onClick={onClick}
45
+ onContextMenu={onContextMenu}
46
+ className={cn(
47
+ // 24×24 invisible hit-target around a 14px dot — touch comfort
48
+ // without bloating the visual.
49
+ 'group relative inline-flex h-6 w-6 items-center justify-center',
50
+ // Keyboard focus ring uses the same emerald accent as selection.
51
+ 'cursor-pointer outline-none rounded-full',
52
+ 'focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-background',
53
+ )}
54
+ >
55
+ <span
56
+ aria-hidden
57
+ className={cn(
58
+ // Inner dot: 14px disc, amber accent, white glyph centered.
59
+ // Drop shadow grounds it against the rendered scene; without
60
+ // it the pin floats and reads as a UI bug.
61
+ 'flex h-3.5 w-3.5 items-center justify-center rounded-full',
62
+ 'text-[8px] font-mono font-bold leading-none text-white tabular-nums',
63
+ 'shadow-[0_1px_4px_rgba(0,0,0,0.35),0_0_0_1px_rgba(0,0,0,0.15)]',
64
+ 'transition-transform duration-150 ease-out',
65
+ 'group-hover:scale-[1.18]',
66
+ // Idle pulse on first paint — drawn from the layer's
67
+ // animation-delay so a freshly committed pin announces
68
+ // itself once and then settles.
69
+ variant === 'idle' && 'annotation-pin-idle',
70
+ variant === 'draft' && 'opacity-70',
71
+ )}
72
+ style={{
73
+ backgroundColor: variant === 'draft' ? '#fbbf24' : '#f59e0b',
74
+ }}
75
+ >
76
+ {glyph}
77
+ </span>
78
+ {selected && (
79
+ <span
80
+ aria-hidden
81
+ // Selection ring — emerald, matches the existing
82
+ // "constructive" accent (Raw STEP nav, duplicate path).
83
+ // Sits one pixel outside the dot via `ring-offset`.
84
+ className="pointer-events-none absolute inset-[5px] rounded-full ring-2 ring-emerald-500 ring-offset-1 ring-offset-transparent"
85
+ />
86
+ )}
87
+ </button>
88
+ );
89
+ },
90
+ );
@@ -0,0 +1,296 @@
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
+ * Annotation popover — appears next to a pin when the user clicks
7
+ * an existing annotation. Read mode shows the note + relative time
8
+ * + entity context; edit mode swaps in a textarea with Enter-to-save
9
+ * / Shift+Enter-newline / Esc-cancel semantics.
10
+ */
11
+
12
+ import { useCallback, useEffect, useRef, useState } from 'react';
13
+ import { Pencil, Trash2, X, Check } from 'lucide-react';
14
+ import { Button } from '@/components/ui/button';
15
+ import { cn } from '@/lib/utils';
16
+ import type { Annotation } from '@/store/slices/annotationsSlice';
17
+
18
+ const MAX_NOTE_LEN = 2000;
19
+ const SOFT_NOTE_LIMIT = 200;
20
+
21
+ export interface AnnotationPopoverProps {
22
+ annotation: Annotation;
23
+ /** Anchor in canvas-relative pixel coordinates. */
24
+ anchorX: number;
25
+ anchorY: number;
26
+ /** Canvas dimensions for edge clamping (so the popover never falls off-screen). */
27
+ canvasWidth: number;
28
+ canvasHeight: number;
29
+ /** Resolved entity type, when the pin is anchored to a known IfcRoot. */
30
+ entityType?: string | null;
31
+ onSave: (note: string) => void;
32
+ onDelete: () => void;
33
+ onClose: () => void;
34
+ }
35
+
36
+ const POPOVER_WIDTH = 280;
37
+ const POPOVER_OFFSET_X = 16;
38
+
39
+ function formatRelativeTime(timestamp: number): string {
40
+ const diff = Date.now() - timestamp;
41
+ const minute = 60_000;
42
+ const hour = 60 * minute;
43
+ const day = 24 * hour;
44
+ const week = 7 * day;
45
+ if (diff < minute) return 'just now';
46
+ if (diff < hour) return `${Math.floor(diff / minute)}m ago`;
47
+ if (diff < day) return `${Math.floor(diff / hour)}h ago`;
48
+ if (diff < week) return `${Math.floor(diff / day)}d ago`;
49
+ return new Date(timestamp).toLocaleDateString();
50
+ }
51
+
52
+ export function AnnotationPopover({
53
+ annotation,
54
+ anchorX,
55
+ anchorY,
56
+ canvasWidth,
57
+ canvasHeight,
58
+ entityType,
59
+ onSave,
60
+ onDelete,
61
+ onClose,
62
+ }: AnnotationPopoverProps) {
63
+ const [editing, setEditing] = useState(annotation.note.length === 0);
64
+ const [draft, setDraft] = useState(annotation.note);
65
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
66
+ const containerRef = useRef<HTMLDivElement>(null);
67
+
68
+ // Reset editor state when the popover is reused for a different
69
+ // annotation. Without this, switching pins would carry the previous
70
+ // pin's draft into the new popover.
71
+ useEffect(() => {
72
+ setEditing(annotation.note.length === 0);
73
+ setDraft(annotation.note);
74
+ }, [annotation.id, annotation.note]);
75
+
76
+ // When the user enters edit mode, focus + select the textarea so
77
+ // typing replaces the existing body cleanly.
78
+ useEffect(() => {
79
+ if (editing && textareaRef.current) {
80
+ textareaRef.current.focus();
81
+ textareaRef.current.select();
82
+ }
83
+ }, [editing]);
84
+
85
+ // Close on outside click. Listening at the document level keeps
86
+ // the popover predictable when the user mouses anywhere else.
87
+ useEffect(() => {
88
+ const handler = (e: MouseEvent) => {
89
+ const node = containerRef.current;
90
+ if (!node) return;
91
+ if (node.contains(e.target as Node)) return;
92
+ // Don't close when the click landed on the same pin — the
93
+ // pin's onClick handler controls open/close itself.
94
+ const closestPin = (e.target as HTMLElement).closest?.('[data-annotation-pin-id]');
95
+ if (closestPin?.getAttribute('data-annotation-pin-id') === annotation.id) return;
96
+ onClose();
97
+ };
98
+ // Defer registration to next tick so the click that opened the
99
+ // popover doesn't immediately close it.
100
+ const id = window.setTimeout(() => {
101
+ document.addEventListener('mousedown', handler);
102
+ }, 0);
103
+ return () => {
104
+ window.clearTimeout(id);
105
+ document.removeEventListener('mousedown', handler);
106
+ };
107
+ }, [annotation.id, onClose]);
108
+
109
+ const handleSave = useCallback(() => {
110
+ onSave(draft);
111
+ setEditing(false);
112
+ }, [draft, onSave]);
113
+
114
+ const handleCancel = useCallback(() => {
115
+ setDraft(annotation.note);
116
+ setEditing(false);
117
+ if (annotation.note.length === 0) {
118
+ // No saved body — user backed out of an edit on a freshly
119
+ // committed pin with no body. Close the popover entirely.
120
+ onClose();
121
+ }
122
+ }, [annotation.note, onClose]);
123
+
124
+ const handleKeyDown = useCallback(
125
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
126
+ if (e.key === 'Enter' && !e.shiftKey) {
127
+ e.preventDefault();
128
+ handleSave();
129
+ } else if (e.key === 'Escape') {
130
+ e.preventDefault();
131
+ handleCancel();
132
+ }
133
+ },
134
+ [handleSave, handleCancel],
135
+ );
136
+
137
+ // Edge clamp the popover. Default: anchor to the right of the pin
138
+ // with a 16px gap; flip left when the right edge would clip.
139
+ const wantsLeft = anchorX + POPOVER_OFFSET_X + POPOVER_WIDTH > canvasWidth;
140
+ const left = wantsLeft
141
+ ? Math.max(8, anchorX - POPOVER_OFFSET_X - POPOVER_WIDTH)
142
+ : Math.min(anchorX + POPOVER_OFFSET_X, canvasWidth - POPOVER_WIDTH - 8);
143
+ const top = Math.min(Math.max(8, anchorY - 12), canvasHeight - 100);
144
+
145
+ const charCountVisible = editing && draft.length >= SOFT_NOTE_LIMIT;
146
+ const overSoftLimit = draft.length > SOFT_NOTE_LIMIT;
147
+ const overHardLimit = draft.length > MAX_NOTE_LEN;
148
+
149
+ return (
150
+ <div
151
+ ref={containerRef}
152
+ role="dialog"
153
+ aria-label="Annotation"
154
+ style={{ left, top, width: POPOVER_WIDTH }}
155
+ className={cn(
156
+ 'absolute z-[60] pointer-events-auto',
157
+ 'rounded-md border border-amber-300/60 dark:border-amber-700/40',
158
+ 'bg-white/95 dark:bg-zinc-950/95 backdrop-blur-md',
159
+ 'shadow-[0_8px_32px_rgba(0,0,0,0.18)]',
160
+ 'overflow-hidden',
161
+ 'animate-in fade-in-0 zoom-in-95 duration-150',
162
+ )}
163
+ >
164
+ {/* Header — entity context + close. Amber accent strip on the
165
+ left signals this is an annotation surface. */}
166
+ <div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-zinc-200 dark:border-zinc-800 bg-amber-50/40 dark:bg-amber-950/20">
167
+ <div className="flex items-center gap-2 min-w-0">
168
+ <span className="h-2 w-2 rounded-full bg-amber-500 shrink-0" aria-hidden />
169
+ <span className="font-mono text-[10px] uppercase tracking-wider text-amber-700 dark:text-amber-300 truncate">
170
+ {entityType ? entityType : 'Annotation'}
171
+ {annotation.entityExpressId !== null && (
172
+ <span className="ml-1 text-zinc-400 dark:text-zinc-500">
173
+ #{annotation.entityExpressId}
174
+ </span>
175
+ )}
176
+ </span>
177
+ </div>
178
+ <Button
179
+ variant="ghost"
180
+ size="icon"
181
+ className="h-5 w-5 p-0 text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200"
182
+ onClick={onClose}
183
+ title="Close"
184
+ >
185
+ <X className="h-3 w-3" />
186
+ </Button>
187
+ </div>
188
+
189
+ {/* Body */}
190
+ <div className="px-3 py-2.5">
191
+ {editing ? (
192
+ <>
193
+ <textarea
194
+ ref={textareaRef}
195
+ value={draft}
196
+ onChange={(e) => setDraft(e.target.value)}
197
+ onKeyDown={handleKeyDown}
198
+ placeholder="Note about this point…"
199
+ rows={4}
200
+ maxLength={MAX_NOTE_LEN + 100}
201
+ className={cn(
202
+ 'w-full resize-none font-mono text-[11px] leading-relaxed',
203
+ 'bg-zinc-50 dark:bg-zinc-900/60 text-zinc-800 dark:text-zinc-200',
204
+ 'border border-zinc-200 dark:border-zinc-800 rounded-sm',
205
+ 'px-2 py-1.5 outline-none focus:ring-1',
206
+ overHardLimit
207
+ ? 'focus:ring-red-400 border-red-300 dark:border-red-700/60'
208
+ : 'focus:ring-amber-400/50 focus:border-amber-300/60',
209
+ )}
210
+ spellCheck
211
+ autoCorrect="on"
212
+ />
213
+ <div className="mt-1.5 flex items-center justify-between gap-2 text-[10px] font-mono">
214
+ <span className="text-zinc-400 dark:text-zinc-500">
215
+ ⏎ save · ⇧⏎ newline · esc cancel
216
+ </span>
217
+ {charCountVisible && (
218
+ <span
219
+ className={cn(
220
+ 'tabular-nums',
221
+ overHardLimit
222
+ ? 'text-red-500'
223
+ : overSoftLimit
224
+ ? 'text-amber-600 dark:text-amber-400'
225
+ : 'text-zinc-400',
226
+ )}
227
+ >
228
+ {draft.length}/{MAX_NOTE_LEN}
229
+ </span>
230
+ )}
231
+ </div>
232
+ <div className="mt-2 flex items-center justify-end gap-1">
233
+ <Button
234
+ variant="ghost"
235
+ size="sm"
236
+ className="h-7 px-2 text-[11px]"
237
+ onClick={handleCancel}
238
+ >
239
+ Cancel
240
+ </Button>
241
+ <Button
242
+ size="sm"
243
+ className="h-7 px-2 text-[11px] bg-amber-500 hover:bg-amber-500/90 text-white"
244
+ onClick={handleSave}
245
+ disabled={overHardLimit}
246
+ >
247
+ <Check className="h-3 w-3 mr-1" />
248
+ Save
249
+ </Button>
250
+ </div>
251
+ </>
252
+ ) : (
253
+ <>
254
+ {annotation.note ? (
255
+ <p className="font-mono text-[11px] leading-relaxed text-zinc-800 dark:text-zinc-200 whitespace-pre-wrap break-words max-h-48 overflow-y-auto">
256
+ {annotation.note}
257
+ </p>
258
+ ) : (
259
+ <p className="font-mono text-[11px] italic text-zinc-400 dark:text-zinc-500">
260
+ (no note — click the pen icon to add one)
261
+ </p>
262
+ )}
263
+ <div className="mt-2 pt-2 border-t border-zinc-200/60 dark:border-zinc-800/60 flex items-center justify-between gap-2">
264
+ <span className="text-[9.5px] font-mono uppercase tracking-wider text-zinc-400 dark:text-zinc-500">
265
+ {formatRelativeTime(annotation.updatedAt)}
266
+ {annotation.updatedAt !== annotation.createdAt && (
267
+ <span className="ml-1">· edited</span>
268
+ )}
269
+ </span>
270
+ <div className="flex items-center gap-0.5">
271
+ <Button
272
+ variant="ghost"
273
+ size="icon"
274
+ className="h-6 w-6 p-0 text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200"
275
+ onClick={() => setEditing(true)}
276
+ title="Edit note"
277
+ >
278
+ <Pencil className="h-3 w-3" />
279
+ </Button>
280
+ <Button
281
+ variant="ghost"
282
+ size="icon"
283
+ className="h-6 w-6 p-0 text-zinc-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30"
284
+ onClick={onDelete}
285
+ title="Delete annotation"
286
+ >
287
+ <Trash2 className="h-3 w-3" />
288
+ </Button>
289
+ </div>
290
+ </div>
291
+ </>
292
+ )}
293
+ </div>
294
+ </div>
295
+ );
296
+ }
@@ -215,7 +215,7 @@ export function BCFTopicDetail({
215
215
  <img
216
216
  src={vp.snapshot}
217
217
  alt="Viewpoint"
218
- className="w-full max-h-48 object-contain bg-muted cursor-pointer hover:opacity-90 transition-opacity"
218
+ className="w-full object-contain cursor-pointer hover:opacity-90 transition-opacity"
219
219
  onClick={() => onActivateViewpoint(vp)}
220
220
  />
221
221
  ) : (
@@ -67,34 +67,27 @@ export function ListPanel({ onClose }: ListPanelProps) {
67
67
 
68
68
  const importInputRef = React.useRef<HTMLInputElement>(null);
69
69
 
70
- // Collect all available data providers for multi-model support
71
- const allProviders = useMemo(() => {
72
- const providers: ListDataProvider[] = [];
70
+ // Build the {modelId, provider} pairs in a single pass so the two
71
+ // arrays can never drift out of alignment (skipping a model without
72
+ // an ifcDataStore must not shift every later model's provider index).
73
+ const modelProviderPairs = useMemo(() => {
74
+ const pairs: Array<{ modelId: string; provider: ListDataProvider }> = [];
73
75
  if (models.size > 0) {
74
- for (const [, model] of models) {
75
- providers.push(createListDataProvider(model.ifcDataStore));
76
+ for (const [modelId, model] of models) {
77
+ // Skip native-metadata models — they don't have a parsed
78
+ // IfcDataStore, so the list provider can't query them.
79
+ if (!model.ifcDataStore) continue;
80
+ pairs.push({ modelId, provider: createListDataProvider(model.ifcDataStore) });
76
81
  }
77
82
  } else if (ifcDataStore) {
78
- providers.push(createListDataProvider(ifcDataStore));
83
+ pairs.push({ modelId: 'default', provider: createListDataProvider(ifcDataStore) });
79
84
  }
80
- return providers;
85
+ return pairs;
81
86
  }, [models, ifcDataStore]);
82
87
 
83
- const hasData = allProviders.length > 0;
88
+ const allProviders = useMemo(() => modelProviderPairs.map((p) => p.provider), [modelProviderPairs]);
84
89
 
85
- // Build a stable map of modelId → provider index for execution
86
- const modelProviderPairs = useMemo(() => {
87
- const pairs: Array<{ modelId: string; provider: ListDataProvider }> = [];
88
- if (models.size > 0) {
89
- let i = 0;
90
- for (const [modelId] of models) {
91
- pairs.push({ modelId, provider: allProviders[i++] });
92
- }
93
- } else if (allProviders.length > 0) {
94
- pairs.push({ modelId: 'default', provider: allProviders[0] });
95
- }
96
- return pairs;
97
- }, [models, allProviders]);
90
+ const hasData = allProviders.length > 0;
98
91
 
99
92
  const handleExecuteList = useCallback((definition: ListDefinition) => {
100
93
  if (!hasData) return;