/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * BCFPanel - BIM Collaboration Format issue management panel * * Provides: * - Topic list with filtering * - Topic detail view with comments * - Viewpoint thumbnails with activation * - Create/edit topics and comments * - Import/export BCF files */ import React, { useCallback, useEffect, useState, useMemo, useRef } from 'react'; import { X, MessageSquare, Upload, Download, User, MapPin, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { useViewerStore } from '@/store'; import { posthog } from '@/lib/analytics'; import type { BCFTopic, BCFViewpoint } from '@ifc-lite/bcf'; import { readBCF, writeBCF, createBCFProject, createBCFTopic, createBCFComment, } from '@ifc-lite/bcf'; import { useBCF } from '@/hooks/useBCF'; import { BCFTopicList } from './bcf/BCFTopicList'; import { BCFTopicDetail } from './bcf/BCFTopicDetail'; import { BCFCreateTopicForm } from './bcf/BCFCreateTopicForm'; import { openGenericFileDialog } from '@/services/file-dialog'; // ============================================================================ // Main BCF Panel Component // ============================================================================ interface BCFPanelProps { onClose: () => void; } export function BCFPanel({ onClose }: BCFPanelProps) { const fileInputRef = useRef(null); // Store state const bcfProject = useViewerStore((s) => s.bcfProject); const setBcfProject = useViewerStore((s) => s.setBcfProject); const activeTopicId = useViewerStore((s) => s.activeTopicId); const setActiveTopic = useViewerStore((s) => s.setActiveTopic); const addTopic = useViewerStore((s) => s.addTopic); const updateTopic = useViewerStore((s) => s.updateTopic); const deleteTopic = useViewerStore((s) => s.deleteTopic); const addComment = useViewerStore((s) => s.addComment); const addViewpoint = useViewerStore((s) => s.addViewpoint); const deleteViewpoint = useViewerStore((s) => s.deleteViewpoint); const bcfAuthor = useViewerStore((s) => s.bcfAuthor); const setBcfAuthor = useViewerStore((s) => s.setBcfAuthor); const setBcfLoading = useViewerStore((s) => s.setBcfLoading); const bcfOverlayVisible = useViewerStore((s) => s.bcfOverlayVisible); const toggleBcfOverlay = useViewerStore((s) => s.toggleBcfOverlay); // Viewer state for capture feedback const selectedEntityId = useViewerStore((s) => s.selectedEntityId); const selectedEntityIds = useViewerStore((s) => s.selectedEntityIds); const hiddenEntities = useViewerStore((s) => s.hiddenEntities); const isolatedEntities = useViewerStore((s) => s.isolatedEntities); // Computed capture state info const selectionCount = useMemo(() => { let count = selectedEntityId !== null ? 1 : 0; count += selectedEntityIds.size; if (selectedEntityId !== null && selectedEntityIds.has(selectedEntityId)) { count--; // Avoid double-counting } return count; }, [selectedEntityId, selectedEntityIds]); const hasIsolation = isolatedEntities !== null && isolatedEntities.size > 0; const hasHiddenEntities = hiddenEntities.size > 0; const setBcfError = useViewerStore((s) => s.setBcfError); const models = useViewerStore((s) => s.models); // BCF hook for camera/snapshot integration const { createViewpointFromState, applyViewpoint, zoomToTopic, canZoomToTopic } = useBCF(); // Local state const [statusFilter, setStatusFilter] = useState('all'); const [showCreateForm, setShowCreateForm] = useState(false); const [showAuthorDialog, setShowAuthorDialog] = useState(false); const [tempAuthor, setTempAuthor] = useState(bcfAuthor); // Viewpoint previewed in the create form and attached to the new topic. const [createViewpoint, setCreateViewpoint] = useState(null); const [capturingSnapshot, setCapturingSnapshot] = useState(false); // Get topics list const topics = useMemo(() => { if (!bcfProject) return []; return Array.from(bcfProject.topics.values()); }, [bcfProject]); // Get active topic const activeTopic = useMemo(() => { if (!bcfProject || !activeTopicId) return null; return bcfProject.topics.get(activeTopicId) || null; }, [bcfProject, activeTopicId]); // Get a default project name from loaded models const getDefaultProjectName = useCallback(() => { if (models.size === 0) { // No models loaded, use date-based name const date = new Date().toISOString().split('T')[0]; return `BCF_Issues_${date}`; } // Use first model's name (without extension) + "_Issues" const firstModel = models.values().next().value; if (firstModel?.name) { const baseName = firstModel.name.replace(/\.(ifc|ifczip)$/i, ''); return `${baseName}_Issues`; } return `BCF_Issues_${new Date().toISOString().split('T')[0]}`; }, [models]); // Initialize project if needed const ensureProject = useCallback(() => { if (!bcfProject) { setBcfProject(createBCFProject({ name: getDefaultProjectName() })); } }, [bcfProject, setBcfProject, getDefaultProjectName]); // Import BCF file const handleImportFile = useCallback(async (file: File | null | undefined) => { if (!file) return; try { setBcfLoading(true); setBcfError(null); const project = await readBCF(file); setBcfProject(project); } catch (error) { console.error('Failed to import BCF:', error); setBcfError(error instanceof Error ? error.message : 'Failed to import BCF file'); } finally { setBcfLoading(false); if (fileInputRef.current) { fileInputRef.current.value = ''; } } }, [setBcfProject, setBcfLoading, setBcfError]); const handleImport = useCallback(async (e: React.ChangeEvent) => { await handleImportFile(e.target.files?.[0]); }, [handleImportFile]); const importFromDialog = useCallback(async (): Promise => { const file = await openGenericFileDialog({ title: 'Import BCF File', filters: [ { name: 'BCF Files', extensions: ['bcfzip', 'bcf'] }, { name: 'All Files', extensions: ['*'] }, ], }); if (file) { await handleImportFile(file); return true; } return false; }, [handleImportFile]); const handleImportClick = useCallback(async () => { const imported = await importFromDialog(); if (imported) { return; } fileInputRef.current?.click(); }, [importFromDialog]); // Export BCF file const handleExport = useCallback(async () => { if (!bcfProject) return; try { setBcfLoading(true); const blob = await writeBCF(bcfProject); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; // Use project name, or generate from model name, or date-based fallback const fileName = bcfProject.name || getDefaultProjectName(); a.download = `${fileName}.bcfzip`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); posthog.capture('bcf_exported', { topic_count: bcfProject.topics.size }); } catch (error) { console.error('Failed to export BCF:', error); setBcfError(error instanceof Error ? error.message : 'Failed to export BCF file'); } finally { setBcfLoading(false); } }, [bcfProject, setBcfLoading, setBcfError, getDefaultProjectName]); // Create new topic // Capture the current view (camera + snapshot + selection) for the create // form's preview and the new topic's attached viewpoint. const captureCreateViewpoint = useCallback(async () => { setCapturingSnapshot(true); try { const vp = await createViewpointFromState({ includeSnapshot: true, includeSelection: true, includeHidden: true, }); setCreateViewpoint(vp); } catch (err) { console.error('[BCFPanel] failed to capture viewpoint for new topic', err); } finally { setCapturingSnapshot(false); } }, [createViewpointFromState]); // Grab a viewpoint when the create form opens; drop it when it closes. useEffect(() => { if (showCreateForm) { void captureCreateViewpoint(); } else { setCreateViewpoint(null); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [showCreateForm]); const handleCreateTopic = useCallback( async (data: Partial, options?: { includeSnapshot: boolean }) => { ensureProject(); const topic = createBCFTopic({ title: data.title || 'Untitled', description: data.description, author: bcfAuthor, topicType: data.topicType, topicStatus: data.topicStatus ?? 'Open', priority: data.priority, assignedTo: data.assignedTo, dueDate: data.dueDate, labels: data.labels, }); addTopic(topic); // Attach the previewed viewpoint unless opted out; fall back to a fresh // capture if the preview never resolved. let viewpoint = options?.includeSnapshot === false ? null : createViewpoint; if (options?.includeSnapshot !== false && !viewpoint) { viewpoint = await createViewpointFromState({ includeSnapshot: true, includeSelection: true, includeHidden: true, }); } if (viewpoint) addViewpoint(topic.guid, viewpoint); posthog.capture('bcf_topic_created', { topic_type: topic.topicType, priority: topic.priority, has_description: Boolean(topic.description), has_viewpoint: Boolean(viewpoint), }); setShowCreateForm(false); }, [ensureProject, bcfAuthor, addTopic, addViewpoint, createViewpoint, createViewpointFromState] ); // Add comment to topic (optionally associated with a viewpoint) const handleAddComment = useCallback( (text: string, viewpointGuid?: string) => { if (!activeTopicId) return; const comment = createBCFComment({ author: bcfAuthor, comment: text, viewpointGuid, // Associate with viewpoint if provided }); addComment(activeTopicId, comment); }, [activeTopicId, bcfAuthor, addComment] ); // Capture viewpoint from current viewer state const handleCaptureViewpoint = useCallback(async () => { if (!activeTopicId) return; // Create viewpoint from current camera, section plane, and selection state const viewpoint = await createViewpointFromState({ includeSnapshot: true, includeSelection: true, includeHidden: true, }); if (viewpoint) { addViewpoint(activeTopicId, viewpoint); } else { console.warn('[BCFPanel] Failed to capture viewpoint - no camera available'); } }, [activeTopicId, addViewpoint, createViewpointFromState]); // Activate viewpoint - apply camera and state to viewer const handleActivateViewpoint = useCallback((viewpoint: BCFViewpoint) => { applyViewpoint(viewpoint, true); // Animate to viewpoint }, [applyViewpoint]); const handleZoomToTopic = useCallback(() => { if (!activeTopic) return; zoomToTopic(activeTopic); }, [activeTopic, zoomToTopic]); // Delete viewpoint const handleDeleteViewpoint = useCallback( (viewpointGuid: string) => { if (!activeTopicId) return; deleteViewpoint(activeTopicId, viewpointGuid); }, [activeTopicId, deleteViewpoint] ); // Update topic status const handleUpdateStatus = useCallback( (status: string) => { if (!activeTopicId) return; updateTopic(activeTopicId, { topicStatus: status, modifiedAuthor: bcfAuthor }); }, [activeTopicId, updateTopic, bcfAuthor] ); // Delete topic const handleDeleteTopic = useCallback(() => { if (!activeTopicId) return; deleteTopic(activeTopicId); setActiveTopic(null); }, [activeTopicId, deleteTopic, setActiveTopic]); // Save author const handleSaveAuthor = useCallback(() => { if (tempAuthor.trim()) { setBcfAuthor(tempAuthor.trim()); } setShowAuthorDialog(false); }, [tempAuthor, setBcfAuthor]); return (
{/* Header */}

BCF Issues

{topics.length > 0 && ( {topics.length} )}
{/* Content */}
{showCreateForm ? ( // Scroll the form — the full field set + snapshot can exceed the panel.
setShowCreateForm(false)} author={bcfAuthor} snapshot={createViewpoint?.snapshot ?? null} onCaptureSnapshot={() => void captureCreateViewpoint()} capturingSnapshot={capturingSnapshot} />
) : activeTopic ? ( setActiveTopic(null)} onAddComment={handleAddComment} onAddViewpoint={handleCaptureViewpoint} onActivateViewpoint={handleActivateViewpoint} onDeleteViewpoint={handleDeleteViewpoint} onUpdateStatus={handleUpdateStatus} onZoomToTopic={handleZoomToTopic} canZoomToTopic={activeTopic ? canZoomToTopic(activeTopic) : false} onDeleteTopic={handleDeleteTopic} selectionCount={selectionCount} hasIsolation={hasIsolation} hasHiddenEntities={hasHiddenEntities} /> ) : ( setShowCreateForm(true)} statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} author={bcfAuthor} onSetAuthor={setBcfAuthor} /> )} {/* Author Dialog */} {showAuthorDialog && (

Set Author Email

setTempAuthor(e.target.value)} placeholder="your@email.com" className="mb-4" />
)}
); }