@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.
- package/.turbo/turbo-build.log +17 -14
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +513 -0
- package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
- package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
- package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
- package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
- package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
- package/dist/assets/index-COnQRuqY.css +1 -0
- package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
- package/dist/assets/sandbox-jez21HtV.js +9627 -0
- package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
- package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +10 -10
- package/src/apache-arrow.d.ts +30 -0
- package/src/components/viewer/AddElementPanel.tsx +758 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
- package/src/components/viewer/ChatPanel.tsx +64 -2
- package/src/components/viewer/CommandPalette.tsx +56 -7
- package/src/components/viewer/EntityContextMenu.tsx +168 -4
- package/src/components/viewer/ExportChangesButton.tsx +25 -5
- package/src/components/viewer/ExportDialog.tsx +19 -1
- package/src/components/viewer/MainToolbar.tsx +69 -10
- package/src/components/viewer/PropertiesPanel.tsx +222 -22
- package/src/components/viewer/SearchInline.tsx +669 -0
- package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
- package/src/components/viewer/SearchModal.filter.tsx +514 -0
- package/src/components/viewer/SearchModal.text.tsx +388 -0
- package/src/components/viewer/SearchModal.tsx +235 -0
- package/src/components/viewer/ToolOverlays.tsx +5 -0
- package/src/components/viewer/ViewerLayout.tsx +24 -4
- package/src/components/viewer/Viewport.tsx +11 -1
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
- package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
- package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
- package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
- package/src/components/viewer/lists/ListPanel.tsx +14 -21
- package/src/components/viewer/properties/RawStepCard.tsx +332 -0
- package/src/components/viewer/properties/RawStepRow.tsx +261 -0
- package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
- package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
- package/src/components/viewer/properties/raw-step-format.ts +193 -0
- package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
- package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
- package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
- package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
- package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
- package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
- package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
- package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
- package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
- package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
- package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
- package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
- package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
- package/src/components/viewer/schedule/generate-schedule.ts +648 -0
- package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
- package/src/components/viewer/schedule/schedule-animator.ts +488 -0
- package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
- package/src/components/viewer/schedule/schedule-selection.ts +163 -0
- package/src/components/viewer/schedule/schedule-utils.ts +223 -0
- package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
- package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
- package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
- package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
- package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +446 -0
- package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/hooks/useIfcLoader.ts +22 -10
- package/src/hooks/useKeyboardShortcuts.ts +25 -0
- package/src/hooks/useSandbox.ts +1 -1
- package/src/hooks/useSearchIndex.ts +125 -0
- package/src/index.css +66 -0
- package/src/lib/llm/system-prompt.test.ts +14 -0
- package/src/lib/llm/system-prompt.ts +102 -1
- package/src/lib/llm/types.ts +6 -0
- package/src/lib/recent-files.ts +38 -4
- package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
- package/src/lib/scripts/templates/construction-schedule.ts +223 -0
- package/src/lib/scripts/templates.ts +7 -0
- package/src/lib/search/common-ifc-types.ts +36 -0
- package/src/lib/search/filter-evaluate.test.ts +537 -0
- package/src/lib/search/filter-evaluate.ts +610 -0
- package/src/lib/search/filter-rules.test.ts +119 -0
- package/src/lib/search/filter-rules.ts +198 -0
- package/src/lib/search/filter-schema.test.ts +233 -0
- package/src/lib/search/filter-schema.ts +146 -0
- package/src/lib/search/recent-searches.test.ts +116 -0
- package/src/lib/search/recent-searches.ts +93 -0
- package/src/lib/search/result-export.test.ts +101 -0
- package/src/lib/search/result-export.ts +104 -0
- package/src/lib/search/saved-filters.test.ts +118 -0
- package/src/lib/search/saved-filters.ts +154 -0
- package/src/lib/search/tier0-scan.test.ts +196 -0
- package/src/lib/search/tier0-scan.ts +237 -0
- package/src/lib/search/tier1-index.test.ts +242 -0
- package/src/lib/search/tier1-index.ts +448 -0
- package/src/sdk/adapters/export-adapter.test.ts +434 -1
- package/src/sdk/adapters/export-adapter.ts +404 -1
- package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
- package/src/sdk/adapters/export-schedule-splice.ts +87 -0
- package/src/sdk/adapters/model-compat.ts +8 -2
- package/src/sdk/adapters/schedule-adapter.ts +73 -0
- package/src/sdk/adapters/store-adapter.ts +201 -0
- package/src/sdk/adapters/visibility-adapter.ts +3 -0
- package/src/sdk/local-backend.ts +16 -8
- package/src/services/desktop-export.ts +3 -1
- package/src/services/desktop-native-metadata.ts +41 -18
- package/src/services/file-dialog.ts +4 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/basketVisibleSet.ts +3 -0
- package/src/store/globalId.ts +4 -1
- package/src/store/index.ts +70 -1
- package/src/store/slices/addElementMeshes.ts +365 -0
- package/src/store/slices/addElementSlice.ts +275 -0
- package/src/store/slices/annotationsSlice.test.ts +133 -0
- package/src/store/slices/annotationsSlice.ts +251 -0
- package/src/store/slices/dataSlice.test.ts +23 -4
- package/src/store/slices/dataSlice.ts +1 -1
- package/src/store/slices/modelSlice.test.ts +67 -9
- package/src/store/slices/modelSlice.ts +39 -7
- package/src/store/slices/mutationSlice.ts +964 -3
- package/src/store/slices/overlayCompositor.test.ts +164 -0
- package/src/store/slices/overlaySlice.test.ts +93 -0
- package/src/store/slices/overlaySlice.ts +151 -0
- package/src/store/slices/pinboardSlice.test.ts +6 -1
- package/src/store/slices/playbackSlice.ts +128 -0
- package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
- package/src/store/slices/schedule-edit-helpers.ts +179 -0
- package/src/store/slices/scheduleSlice.test.ts +694 -0
- package/src/store/slices/scheduleSlice.ts +1330 -0
- package/src/store/slices/searchSlice.test.ts +342 -0
- package/src/store/slices/searchSlice.ts +341 -0
- package/src/store/slices/selectionSlice.test.ts +46 -0
- package/src/store/slices/selectionSlice.ts +20 -0
- package/src/store.ts +14 -0
- package/dist/assets/index-_bfZsDCC.css +0 -1
- package/dist/assets/sandbox-C8575tul.js +0 -5951
|
@@ -225,6 +225,13 @@ export function BulkPropertyEditor({ trigger }: BulkPropertyEditorProps) {
|
|
|
225
225
|
// Yield to browser so dialog shell + spinner paint first
|
|
226
226
|
initTimerRef.current = setTimeout(() => {
|
|
227
227
|
const dataStore = selectedModel.ifcDataStore;
|
|
228
|
+
// The early return above already gates on `selectedModel?.ifcDataStore`,
|
|
229
|
+
// but the closure-captured `selectedModel` reference is union-typed,
|
|
230
|
+
// so re-narrow here for the timeout callback.
|
|
231
|
+
if (!dataStore) {
|
|
232
|
+
setIsInitializing(false);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
228
235
|
const entities = dataStore.entities;
|
|
229
236
|
|
|
230
237
|
// Storeys
|
|
@@ -79,11 +79,24 @@ const MAX_INLINE_IMAGE_DATA_URL_CHARS = 1_200_000;
|
|
|
79
79
|
const MAX_ATTACHMENTS_PER_MESSAGE = 6;
|
|
80
80
|
const MAX_TEXT_ATTACHMENT_BYTES = 512_000;
|
|
81
81
|
const MAX_IMAGE_ATTACHMENT_BYTES = 8_000_000;
|
|
82
|
+
/** Anthropic's PDF content-block limit is ~32 MB; keep our upload cap lower. */
|
|
83
|
+
const MAX_PDF_ATTACHMENT_BYTES = 16_000_000;
|
|
82
84
|
|
|
83
85
|
function createAttachmentId(): string {
|
|
84
86
|
return crypto.randomUUID();
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
/** Convert an ArrayBuffer (binary file) to raw base64 — no data-URL prefix. */
|
|
90
|
+
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
91
|
+
const bytes = new Uint8Array(buffer);
|
|
92
|
+
let binary = '';
|
|
93
|
+
const chunk = 0x8000;
|
|
94
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
95
|
+
binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
|
|
96
|
+
}
|
|
97
|
+
return btoa(binary);
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
interface ChatSendOptions {
|
|
88
101
|
continuationBase?: string;
|
|
89
102
|
intent?: ScriptMutationIntent;
|
|
@@ -1019,7 +1032,54 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
|
|
|
1019
1032
|
remainingSlots -= 1;
|
|
1020
1033
|
continue;
|
|
1021
1034
|
}
|
|
1022
|
-
//
|
|
1035
|
+
// PDFs are supported by Claude as native document content blocks.
|
|
1036
|
+
// Route them separately from text attachments so the chat request
|
|
1037
|
+
// can emit the correct multimodal block type.
|
|
1038
|
+
if (file.name.match(/\.pdf$/i) || file.type === 'application/pdf') {
|
|
1039
|
+
if (!supportsFileAttachments) {
|
|
1040
|
+
setChatError('Selected model does not support file attachments. Switch model to attach PDFs.');
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
if (file.size > MAX_PDF_ATTACHMENT_BYTES) {
|
|
1044
|
+
setChatError(`PDF attachments must be smaller than ${Math.round(MAX_PDF_ATTACHMENT_BYTES / 1_000_000)} MB.`);
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
const buffer = await file.arrayBuffer();
|
|
1048
|
+
const base64 = arrayBufferToBase64(buffer);
|
|
1049
|
+
const attachment: FileAttachment = {
|
|
1050
|
+
id: createAttachmentId(),
|
|
1051
|
+
name: file.name,
|
|
1052
|
+
type: 'application/pdf',
|
|
1053
|
+
size: file.size,
|
|
1054
|
+
pdfBase64: base64,
|
|
1055
|
+
isPdf: true,
|
|
1056
|
+
};
|
|
1057
|
+
addAttachment(attachment);
|
|
1058
|
+
remainingSlots -= 1;
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
// Excel / ODS binaries — we can't parse them yet, but we don't want
|
|
1062
|
+
// to silently drop them. Register a metadata-only attachment so the
|
|
1063
|
+
// user (and the LLM via the system prompt) know it's there and can
|
|
1064
|
+
// suggest exporting as CSV.
|
|
1065
|
+
if (file.name.match(/\.(xlsx|xls|ods)$/i)) {
|
|
1066
|
+
if (!supportsFileAttachments) {
|
|
1067
|
+
setChatError('Selected model does not support file attachments. Switch model to attach spreadsheets.');
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
const attachment: FileAttachment = {
|
|
1071
|
+
id: createAttachmentId(),
|
|
1072
|
+
name: file.name,
|
|
1073
|
+
type: file.type || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
1074
|
+
size: file.size,
|
|
1075
|
+
isSpreadsheetBinary: true,
|
|
1076
|
+
textContent: `[Binary spreadsheet ${file.name} (${Math.round(file.size / 1024)} KB). Export to CSV for full content access.]`,
|
|
1077
|
+
};
|
|
1078
|
+
addAttachment(attachment);
|
|
1079
|
+
remainingSlots -= 1;
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
// Text-based files — CSV, TSV, JSON, TXT
|
|
1023
1083
|
if (!file.name.match(/\.(csv|json|txt|tsv)$/i)) continue;
|
|
1024
1084
|
if (!supportsFileAttachments) {
|
|
1025
1085
|
setChatError('Selected model does not support file attachments. Switch model to attach files.');
|
|
@@ -1117,7 +1177,9 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
|
|
|
1117
1177
|
const modelSupportsImages = modelForUi?.supportsImages ?? false;
|
|
1118
1178
|
const modelSupportsFiles = modelForUi?.supportsFileAttachments ?? true;
|
|
1119
1179
|
const attachmentAccept = [
|
|
1120
|
-
modelSupportsFiles
|
|
1180
|
+
modelSupportsFiles
|
|
1181
|
+
? '.csv,.json,.txt,.tsv,.pdf,application/pdf,.xlsx,.xls,.ods,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel,application/vnd.oasis.opendocument.spreadsheet'
|
|
1182
|
+
: '',
|
|
1121
1183
|
modelSupportsImages ? 'image/*' : '',
|
|
1122
1184
|
].filter(Boolean).join(',');
|
|
1123
1185
|
const canAttachInput = modelSupportsFiles || modelSupportsImages;
|
|
@@ -53,6 +53,11 @@ import {
|
|
|
53
53
|
FolderOpen,
|
|
54
54
|
Clock,
|
|
55
55
|
Save,
|
|
56
|
+
CalendarClock,
|
|
57
|
+
CalendarPlus,
|
|
58
|
+
Sparkles,
|
|
59
|
+
Eraser,
|
|
60
|
+
MapPin,
|
|
56
61
|
} from 'lucide-react';
|
|
57
62
|
import { cn } from '@/lib/utils';
|
|
58
63
|
import { useViewerStore } from '@/store';
|
|
@@ -207,23 +212,28 @@ function activateRightPanel(panel: 'bcf' | 'ids' | 'lens') {
|
|
|
207
212
|
// If was active → all closed → falls back to Properties
|
|
208
213
|
}
|
|
209
214
|
|
|
210
|
-
/** Exclusively activate a bottom panel (Script / List).
|
|
215
|
+
/** Exclusively activate a bottom panel (Script / List / Gantt).
|
|
211
216
|
* Closes the other first so the if-else chain in ViewerLayout renders it.
|
|
212
217
|
* If the target is already active, closes it. */
|
|
213
|
-
function activateBottomPanel(panel: 'script' | 'list') {
|
|
218
|
+
function activateBottomPanel(panel: 'script' | 'list' | 'gantt') {
|
|
214
219
|
const s = useViewerStore.getState();
|
|
215
|
-
const isActive =
|
|
220
|
+
const isActive =
|
|
221
|
+
panel === 'script' ? s.scriptPanelVisible
|
|
222
|
+
: panel === 'list' ? s.listPanelVisible
|
|
223
|
+
: s.ganttPanelVisible;
|
|
216
224
|
|
|
217
225
|
closeActiveAnalysisExtension();
|
|
218
226
|
|
|
219
|
-
// Close all bottom panels
|
|
227
|
+
// Close all bottom panels — only one slots into the bottom strip at a time.
|
|
220
228
|
s.setScriptPanelVisible(false);
|
|
221
229
|
s.setListPanelVisible(false);
|
|
230
|
+
s.setGanttPanelVisible(false);
|
|
222
231
|
|
|
223
232
|
if (!isActive) {
|
|
224
233
|
s.setRightPanelCollapsed(false);
|
|
225
234
|
if (panel === 'script') s.setScriptPanelVisible(true);
|
|
226
|
-
else s.setListPanelVisible(true);
|
|
235
|
+
else if (panel === 'list') s.setListPanelVisible(true);
|
|
236
|
+
else s.setGanttPanelVisible(true);
|
|
227
237
|
}
|
|
228
238
|
}
|
|
229
239
|
|
|
@@ -322,6 +332,10 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
|
|
322
332
|
action: () => { useViewerStore.getState().setActiveTool('measure'); } },
|
|
323
333
|
{ id: 'tool:section', label: 'Section', keywords: 'clip cut plane', category: 'Tools', icon: Scissors, shortcut: 'X',
|
|
324
334
|
action: () => { useViewerStore.getState().setActiveTool('section'); } },
|
|
335
|
+
{ id: 'tool:annotate', label: 'Annotate', keywords: 'pin note comment marker', category: 'Tools', icon: MapPin, shortcut: 'P',
|
|
336
|
+
action: () => { useViewerStore.getState().setActiveTool('annotate'); } },
|
|
337
|
+
{ id: 'tool:add-element', label: 'Add Element', keywords: 'wall slab beam column place drop new add element generic', category: 'Tools', icon: Box,
|
|
338
|
+
action: () => { useViewerStore.getState().setActiveTool('addElement'); } },
|
|
325
339
|
);
|
|
326
340
|
|
|
327
341
|
// ── Visibility ──
|
|
@@ -362,7 +376,7 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
|
|
362
376
|
|
|
363
377
|
// ── Panels ──
|
|
364
378
|
c.push(
|
|
365
|
-
{ id: 'panel:properties', label: '
|
|
379
|
+
{ id: 'panel:properties', label: 'Inspector', keywords: 'properties attributes material classification schedule task panel right', category: 'Panels', icon: Layout,
|
|
366
380
|
action: () => { const s = useViewerStore.getState(); s.setRightPanelCollapsed(!s.rightPanelCollapsed); } },
|
|
367
381
|
{ id: 'panel:tree', label: 'Spatial Tree', keywords: 'hierarchy left panel', category: 'Panels', icon: TreeDeciduous,
|
|
368
382
|
action: () => { const s = useViewerStore.getState(); s.setLeftPanelCollapsed(!s.leftPanelCollapsed); } },
|
|
@@ -372,12 +386,47 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
|
|
372
386
|
action: () => { activateRightPanel('bcf'); } },
|
|
373
387
|
{ id: 'panel:ids', label: 'IDS Validation', keywords: 'information delivery specification check', category: 'Panels', icon: ClipboardCheck,
|
|
374
388
|
action: () => { activateRightPanel('ids'); } },
|
|
375
|
-
{ id: 'panel:lists', label: 'Entity Lists', keywords: 'table spreadsheet
|
|
389
|
+
{ id: 'panel:lists', label: 'Entity Lists', keywords: 'table spreadsheet', category: 'Panels', icon: FileSpreadsheet,
|
|
376
390
|
action: () => { activateBottomPanel('list'); } },
|
|
391
|
+
{ id: 'panel:gantt', label: 'Construction Schedule (Gantt)', keywords: '4d timeline tasks ifctask sequence playback animation', category: 'Panels', icon: CalendarClock,
|
|
392
|
+
action: () => { activateBottomPanel('gantt'); } },
|
|
377
393
|
{ id: 'panel:lens', label: 'Lens Rules', keywords: 'color filter highlight', category: 'Panels', icon: Palette,
|
|
378
394
|
action: () => { activateRightPanel('lens'); } },
|
|
379
395
|
);
|
|
380
396
|
|
|
397
|
+
// ── Schedule / 4D (Tools) ─────────────────────────────
|
|
398
|
+
c.push(
|
|
399
|
+
{ id: 'schedule:generate', label: 'Generate Schedule from Storeys…',
|
|
400
|
+
keywords: '4d ifctask construction sequence storey building create gantt',
|
|
401
|
+
category: 'Tools', icon: CalendarPlus,
|
|
402
|
+
action: () => {
|
|
403
|
+
// Make sure the Gantt panel is mounted so the dialog has a host
|
|
404
|
+
// before flipping the dialog flag. Order matters — closing other
|
|
405
|
+
// panels first prevents the bottom strip from rendering them.
|
|
406
|
+
const s = useViewerStore.getState();
|
|
407
|
+
if (!s.ganttPanelVisible) activateBottomPanel('gantt');
|
|
408
|
+
// Same tick is fine — the dialog is portalled via Radix and doesn't
|
|
409
|
+
// depend on GanttPanel finishing its first render.
|
|
410
|
+
useViewerStore.getState().setGenerateScheduleDialogOpen(true);
|
|
411
|
+
} },
|
|
412
|
+
{ id: 'schedule:toggle-animation', label: 'Toggle 4D Construction Animation',
|
|
413
|
+
keywords: 'play pause schedule task gantt simulation',
|
|
414
|
+
category: 'Visibility', icon: Sparkles,
|
|
415
|
+
action: () => {
|
|
416
|
+
const s = useViewerStore.getState();
|
|
417
|
+
s.setAnimationEnabled(!s.animationEnabled);
|
|
418
|
+
} },
|
|
419
|
+
{ id: 'schedule:reset', label: 'Reset Schedule (Clear 4D Data)',
|
|
420
|
+
keywords: 'remove gantt tasks ifctask delete clear',
|
|
421
|
+
category: 'Tools', icon: Eraser,
|
|
422
|
+
action: () => {
|
|
423
|
+
const s = useViewerStore.getState();
|
|
424
|
+
s.setScheduleData(null);
|
|
425
|
+
s.setAnimationEnabled(false);
|
|
426
|
+
s.pauseSchedule();
|
|
427
|
+
} },
|
|
428
|
+
);
|
|
429
|
+
|
|
381
430
|
// ── Export ──
|
|
382
431
|
c.push(
|
|
383
432
|
{ id: 'export:screenshot', label: 'Screenshot', keywords: 'capture png image viewport', category: 'Export', icon: Camera,
|
|
@@ -18,8 +18,11 @@ import {
|
|
|
18
18
|
Maximize2,
|
|
19
19
|
Building2,
|
|
20
20
|
Save,
|
|
21
|
+
Trash2,
|
|
22
|
+
CopyPlus,
|
|
21
23
|
} from 'lucide-react';
|
|
22
24
|
import { useViewerStore, resolveEntityRef } from '@/store';
|
|
25
|
+
import type { DuplicateDirection } from '@/store/slices/mutationSlice';
|
|
23
26
|
import { resetVisibilityForHomeFromStore } from '@/store/homeView';
|
|
24
27
|
import {
|
|
25
28
|
executeBasketSet,
|
|
@@ -28,6 +31,7 @@ import {
|
|
|
28
31
|
executeBasketSaveView,
|
|
29
32
|
} from '@/store/basket/basketCommands';
|
|
30
33
|
import { useIfc } from '@/hooks/useIfc';
|
|
34
|
+
import { toast } from '@/components/ui/toast';
|
|
31
35
|
|
|
32
36
|
export function EntityContextMenu() {
|
|
33
37
|
const contextMenu = useViewerStore((s) => s.contextMenu);
|
|
@@ -36,6 +40,10 @@ export function EntityContextMenu() {
|
|
|
36
40
|
const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
|
|
37
41
|
const setSelectedEntityIds = useViewerStore((s) => s.setSelectedEntityIds);
|
|
38
42
|
const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks);
|
|
43
|
+
// Store-level mutations
|
|
44
|
+
const removeEntity = useViewerStore((s) => s.removeEntity);
|
|
45
|
+
const duplicateEntity = useViewerStore((s) => s.duplicateEntity);
|
|
46
|
+
const getMutationView = useViewerStore((s) => s.getMutationView);
|
|
39
47
|
// Basket actions
|
|
40
48
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
41
49
|
const { ifcDataStore, models } = useIfc();
|
|
@@ -206,6 +214,63 @@ export function EntityContextMenu() {
|
|
|
206
214
|
closeContextMenu();
|
|
207
215
|
}, [resolvedExpressId, activeDataStore, closeContextMenu]);
|
|
208
216
|
|
|
217
|
+
// Right-clicked entity's type — used in the toast message.
|
|
218
|
+
const contextEntityType = useMemo(() => {
|
|
219
|
+
if (!resolvedExpressId || !activeDataStore) return '';
|
|
220
|
+
return activeDataStore.entities.getTypeName(resolvedExpressId) || '';
|
|
221
|
+
}, [resolvedExpressId, activeDataStore]);
|
|
222
|
+
|
|
223
|
+
// Mutation view is required to drive bim.store.* — native-metadata-only
|
|
224
|
+
// models don't have one, so the Delete option stays hidden there.
|
|
225
|
+
const canEdit = useMemo(() => {
|
|
226
|
+
if (!contextEntityRef) return false;
|
|
227
|
+
return getMutationView(contextEntityRef.modelId) !== null;
|
|
228
|
+
}, [contextEntityRef, getMutationView]);
|
|
229
|
+
|
|
230
|
+
const handleDuplicate = useCallback(
|
|
231
|
+
(direction: DuplicateDirection = '+X') => {
|
|
232
|
+
if (!contextEntityRef || !canEdit) {
|
|
233
|
+
closeContextMenu();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const result = duplicateEntity(contextEntityRef.modelId, contextEntityRef.expressId, direction);
|
|
237
|
+
if ('error' in result) {
|
|
238
|
+
toast.error(`Couldn't duplicate: ${result.error}`);
|
|
239
|
+
} else {
|
|
240
|
+
// Move selection onto the new entity so the property panel
|
|
241
|
+
// refreshes and the user can keep iterating (Cmd+D again
|
|
242
|
+
// duplicates the duplicate, like a stamp tool).
|
|
243
|
+
setSelectedEntityId(result.globalId);
|
|
244
|
+
toast.success(`Duplicated as #${result.expressId} (${direction}) — undo to remove`);
|
|
245
|
+
}
|
|
246
|
+
closeContextMenu();
|
|
247
|
+
},
|
|
248
|
+
[contextEntityRef, canEdit, duplicateEntity, setSelectedEntityId, closeContextMenu],
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const handleDeleteEntity = useCallback(() => {
|
|
252
|
+
if (!contextEntityRef || !canEdit || !contextMenu.entityId) {
|
|
253
|
+
closeContextMenu();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const ok = removeEntity(contextEntityRef.modelId, contextEntityRef.expressId);
|
|
257
|
+
if (ok) {
|
|
258
|
+
// Tombstoning only affects export — the rendered mesh is still
|
|
259
|
+
// in the GPU buffers. Hide it via the existing visibility system
|
|
260
|
+
// so the entity disappears from the scene and stops being
|
|
261
|
+
// pickable. `Show all` from the empty-space menu restores it
|
|
262
|
+
// (along with re-running undo to bring back the overlay).
|
|
263
|
+
hideEntity(contextMenu.entityId);
|
|
264
|
+
// Drop the selection so the right panel doesn't cling to a
|
|
265
|
+
// tombstoned id.
|
|
266
|
+
setSelectedEntityId(null);
|
|
267
|
+
toast.success(`${contextEntityType || 'Entity'} #${contextEntityRef.expressId} deleted — undo to restore`);
|
|
268
|
+
} else {
|
|
269
|
+
toast.error('Delete failed — entity not found in store overlay');
|
|
270
|
+
}
|
|
271
|
+
closeContextMenu();
|
|
272
|
+
}, [contextEntityRef, canEdit, contextEntityType, contextMenu.entityId, removeEntity, hideEntity, setSelectedEntityId, closeContextMenu]);
|
|
273
|
+
|
|
209
274
|
if (!contextMenu.isOpen) {
|
|
210
275
|
return null;
|
|
211
276
|
}
|
|
@@ -257,6 +322,22 @@ export function EntityContextMenu() {
|
|
|
257
322
|
<div className="h-px bg-border my-1" />
|
|
258
323
|
|
|
259
324
|
<MenuItem icon={Copy} label="Copy GlobalId" onClick={handleCopyId} />
|
|
325
|
+
|
|
326
|
+
{/* Store-level mutations (bim.store.*). Only surfaced when there's
|
|
327
|
+
a live mutation view on the model — otherwise these would
|
|
328
|
+
silently no-op and confuse users. */}
|
|
329
|
+
{canEdit && (
|
|
330
|
+
<>
|
|
331
|
+
<div className="h-px bg-border my-1" />
|
|
332
|
+
<DuplicateRow onDuplicate={handleDuplicate} />
|
|
333
|
+
<MenuItem
|
|
334
|
+
icon={Trash2}
|
|
335
|
+
label="Delete entity"
|
|
336
|
+
tone="destructive"
|
|
337
|
+
onClick={handleDeleteEntity}
|
|
338
|
+
/>
|
|
339
|
+
</>
|
|
340
|
+
)}
|
|
260
341
|
</>
|
|
261
342
|
)}
|
|
262
343
|
|
|
@@ -269,22 +350,105 @@ export function EntityContextMenu() {
|
|
|
269
350
|
);
|
|
270
351
|
}
|
|
271
352
|
|
|
353
|
+
type MenuItemTone = 'default' | 'destructive';
|
|
354
|
+
|
|
272
355
|
interface MenuItemProps {
|
|
273
356
|
icon: React.ComponentType<{ className?: string }>;
|
|
274
357
|
label: string;
|
|
275
358
|
onClick: () => void;
|
|
276
359
|
disabled?: boolean;
|
|
360
|
+
/** Right-aligned keyboard hint (e.g. `'⌘D'`). */
|
|
361
|
+
shortcut?: string;
|
|
362
|
+
/**
|
|
363
|
+
* Visual tone:
|
|
364
|
+
* - `default` muted icon, neutral hover
|
|
365
|
+
* - `destructive` red-toned icon and red-tinted hover (Delete entity)
|
|
366
|
+
*/
|
|
367
|
+
tone?: MenuItemTone;
|
|
277
368
|
}
|
|
278
369
|
|
|
279
|
-
|
|
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=
|
|
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=
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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;
|