import { type AnyNodeId, type AnyNode, type BuildingNode, emitter, LevelNode, type SiteNode, useScene, type ZoneNode, type ScanNode, type GuideNode, } from "@pascal-app/core"; import { useViewer } from "@pascal-app/viewer"; import { Box, Building2, Camera, ChevronDown, Image as ImageIcon, Layers, Loader2, Pentagon, MoreHorizontal, Pencil, Plus, Trash2, X, } from "lucide-react"; import { useState, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; import useEditor from "@/store/use-editor"; import { TreeNode } from "./tree-node"; import { InlineRenameInput } from "./inline-rename-input"; import { useProjectStore } from '@/features/community/lib/projects/store'; import { deleteProjectAssetByUrl } from '@/features/community/lib/assets/actions'; import { useUploadStore } from '@/store/use-upload'; import { uploadAssetWithProgress } from '@/lib/upload-asset'; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/primitives/popover"; import { motion, AnimatePresence, LayoutGroup } from "motion/react"; import { ColorDot } from "@/components/ui/primitives/color-dot"; // ============================================================================ // PROPERTY LINE SECTION // ============================================================================ function calculatePerimeter(points: Array<[number, number]>): number { if (points.length < 2) return 0; let perimeter = 0; for (let i = 0; i < points.length; i++) { const [x1, z1] = points[i]!; const [x2, z2] = points[(i + 1) % points.length]!; perimeter += Math.sqrt((x2 - x1) ** 2 + (z2 - z1) ** 2); } return perimeter; } function calculatePolygonArea(polygon: Array<[number, number]>): number { if (polygon.length < 3) return 0; let area = 0; const n = polygon.length; for (let i = 0; i < n; i++) { const j = (i + 1) % n; area += polygon[i]![0] * polygon[j]![1]; area -= polygon[j]![0] * polygon[i]![1]; } return Math.abs(area) / 2; } function useSiteNode(): SiteNode | null { const siteId = useScene((state) => { for (const id of state.rootNodeIds) { if (state.nodes[id]?.type === "site") return id; } return null; }); return useScene((state) => siteId ? ((state.nodes[siteId] as SiteNode | undefined) ?? null) : null ); } function PropertyLineSection() { const siteNode = useSiteNode(); const updateNode = useScene((state) => state.updateNode); const mode = useEditor((state) => state.mode); const setMode = useEditor((state) => state.setMode); if (!siteNode) return null; const points = siteNode.polygon?.points ?? []; const area = calculatePolygonArea(points); const perimeter = calculatePerimeter(points); const isEditing = mode === "edit"; const handleToggleEdit = () => { setMode(isEditing ? "select" : "edit"); }; const handlePointChange = (index: number, axis: 0 | 1, value: number) => { const newPoints = [...points.map((p) => [...p] as [number, number])]; newPoints[index]![axis] = value; updateNode(siteNode.id, { polygon: { type: "polygon" as const, points: newPoints }, }); }; const handleAddPoint = () => { const lastPoint = points[points.length - 1]; const firstPoint = points[0]; if (!lastPoint || !firstPoint) return; const newPoint: [number, number] = [ (lastPoint[0] + firstPoint[0]) / 2, (lastPoint[1] + firstPoint[1]) / 2, ]; const newPoints = [...points, newPoint]; updateNode(siteNode.id, { polygon: { type: "polygon" as const, points: newPoints }, }); }; const handleDeletePoint = (index: number) => { if (points.length <= 3) return; const newPoints = points.filter((_, i) => i !== index); updateNode(siteNode.id, { polygon: { type: "polygon" as const, points: newPoints }, }); }; return (
{/* Vertical tree line */}
{/* Header */}
{/* Horizontal branch line */}
Property Line
{/* Measurements */}
Area: {area.toFixed(1)} m²
Perimeter:{" "} {perimeter.toFixed(1)} m
{/* Vertex list (shown when editing) */} {isEditing && (
{points.map((point, index) => (
{index + 1} handlePointChange(index, 0, parseFloat(e.target.value) || 0) } step={0.5} className="w-16 bg-accent/50 rounded px-1.5 py-0.5 text-xs text-foreground border border-border/50 focus:outline-none focus:border-primary" /> handlePointChange(index, 1, parseFloat(e.target.value) || 0) } step={0.5} className="w-16 bg-accent/50 rounded px-1.5 py-0.5 text-xs text-foreground border border-border/50 focus:outline-none focus:border-primary" />
))}
)}
); } // ============================================================================ // SITE PHASE VIEW - Property line + building buttons // ============================================================================ function CameraPopover({ nodeId, hasCamera, open, onOpenChange, buttonClassName, }: { nodeId: AnyNodeId; hasCamera: boolean; open: boolean; onOpenChange: (open: boolean) => void; buttonClassName?: string; }) { const updateNode = useScene((state) => state.updateNode); return ( e.stopPropagation()} >
{hasCamera && ( )} {hasCamera && ( )}
); } function ReferenceItem({ refNode, isLastRow, setSelectedReferenceId, handleDelete }: { refNode: ScanNode | GuideNode; isLastRow: boolean; setSelectedReferenceId: (id: string) => void; handleDelete: (id: string, e: React.MouseEvent) => void; }) { const [isEditing, setIsEditing] = useState(false); return (
setSelectedReferenceId(refNode.id)} onDoubleClick={() => setIsEditing(true)} > {refNode.type === 'scan' ? Scan : Guide} setIsEditing(false)} onStartEditing={() => setIsEditing(true)} defaultName={refNode.type === 'scan' ? '3D Scan' : 'Guide Image'} />
); } const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB function LevelReferences({ levelId, isLastLevel }: { levelId: string, isLastLevel?: boolean }) { const nodes = useScene((s) => s.nodes); const deleteNode = useScene((s) => s.deleteNode); const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId); const activeProject = useProjectStore((s) => s.activeProject); const uploadState = useUploadStore((s) => s.uploads[levelId]); const clearUpload = useUploadStore((s) => s.clearUpload); const uploading = uploadState?.status === 'preparing' || uploadState?.status === 'uploading' || uploadState?.status === 'confirming'; const uploadingType = uploadState?.assetType ?? null; const uploadError = uploadState?.error ?? null; const progress = uploadState?.progress ?? 0; const scanInputRef = useRef(null); const references = Object.values(nodes).filter( (node): node is ScanNode | GuideNode => (node.type === 'scan' || node.type === 'guide') && node.parentId === levelId, ); const handleAddAsset = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; e.target.value = ''; const projectId = activeProject?.id; if (!projectId) { useUploadStore.getState().startUpload(levelId, 'scan', file.name); useUploadStore.getState().setError(levelId, 'No active project. Please open a project first.'); return; } if (file.size > MAX_FILE_SIZE) { useUploadStore.getState().startUpload(levelId, 'scan', file.name); useUploadStore.getState().setError(levelId, `File is too large (${(file.size / 1024 / 1024).toFixed(0)} MB). Maximum size is 200 MB.`); return; } // Auto-detect type based on file extension/mime type const isScan = file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf'); const isImage = file.type.startsWith('image/'); if (!isScan && !isImage) { useUploadStore.getState().startUpload(levelId, 'scan', file.name); useUploadStore.getState().setError(levelId, 'Invalid file type. Please upload a .glb/.gltf scan or an image.'); return; } const type = isScan ? 'scan' : 'guide'; clearUpload(levelId); uploadAssetWithProgress(projectId, levelId, file, type); }; const handleDelete = async (nodeId: string, e: React.MouseEvent) => { e.stopPropagation(); const refNode = nodes[nodeId as AnyNodeId] as ScanNode | GuideNode | undefined; const projectId = activeProject?.id; if ( projectId && refNode?.url && (refNode.url.startsWith('http://') || refNode.url.startsWith('https://')) ) { deleteProjectAssetByUrl(projectId, refNode.url); } deleteNode(nodeId as AnyNodeId); }; const rows = [ { type: 'upload' as const }, ...references.map(ref => ({ type: 'ref' as const, data: ref })) ]; return (
{!isLastLevel && (
)} {rows.map((row, i) => { const isLastRow = i === rows.length - 1; if (row.type === 'upload') { return (
); } const ref = row.data as ScanNode | GuideNode; return ( ); })} {uploadError && (
{uploadError}
)}
); } function LevelItem({ level, selectedLevelId, setSelection, deleteNode, updateNode, isLast, }: { level: LevelNode; selectedLevelId: string | null; setSelection: (selection: any) => void; deleteNode: (id: AnyNodeId) => void; updateNode: (id: AnyNodeId, updates: Partial) => void; isLast?: boolean; }) { const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false); const [isEditing, setIsEditing] = useState(false); const itemRef = useRef(null); const isSelected = selectedLevelId === level.id; const [isExpanded, setIsExpanded] = useState(isSelected); useEffect(() => { setIsExpanded(isSelected); }, [isSelected]); useEffect(() => { if (isSelected && itemRef.current) { itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } }, [isSelected]); return (
{/* Vertical tree line */}
{/* Horizontal branch line */}
{/* Line down to children */} {isExpanded && (
)}
setSelection({ levelId: level.id })} onDoubleClick={() => setIsEditing(true)} > Level setIsEditing(false)} onStartEditing={() => setIsEditing(true)} defaultName={`Level ${level.level}`} />
{/* Camera snapshot button */} e.stopPropagation()} >
{level.camera && ( )} {level.camera && ( )}
{level.level !== 0 && ( )}
{isExpanded && ( )}
); } function LevelsSection() { const nodes = useScene((state) => state.nodes); const createNode = useScene((state) => state.createNode); const updateNode = useScene((state) => state.updateNode); const deleteNode = useScene((state) => state.deleteNode); const selectedBuildingId = useViewer((state) => state.selection.buildingId); const selectedLevelId = useViewer((state) => state.selection.levelId); const setSelection = useViewer((state) => state.setSelection); const building = selectedBuildingId ? (nodes[selectedBuildingId] as BuildingNode) : null; if (!building) return null; const levels = building.children .map((id) => nodes[id]) .filter((node): node is LevelNode => node?.type === "level"); const handleAddLevel = () => { const newLevel = LevelNode.parse({ level: levels.length, children: [], parentId: building.id, }); createNode(newLevel, building.id); setSelection({ levelId: newLevel.id }); }; return (
{/* Level buttons */}
{levels.length === 0 && (
{/* Vertical tree line */}
{/* Horizontal branch line */}
No levels yet
)} {[...levels].reverse().map((level, index) => ( ))}
); } function LayerToggle() { const structureLayer = useEditor((state) => state.structureLayer); const setStructureLayer = useEditor((state) => state.setStructureLayer); const phase = useEditor((state) => state.phase); const setPhase = useEditor((state) => state.setPhase); const activeTab = phase === "structure" && structureLayer === "elements" ? "structure" : phase === "furnish" ? "furnish" : phase === "structure" && structureLayer === "zones" ? "zones" : "none"; return (
); } function ZoneItem({ zone, isLast }: { zone: ZoneNode, isLast?: boolean }) { const [isEditing, setIsEditing] = useState(false); const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false); const deleteNode = useScene((state) => state.deleteNode); const updateNode = useScene((state) => state.updateNode); const selectedZoneId = useViewer((state) => state.selection.zoneId); const hoveredId = useViewer((state) => state.hoveredId); const setSelection = useViewer((state) => state.setSelection); const setHoveredId = useViewer((state) => state.setHoveredId); const setPhase = useEditor((state) => state.setPhase); const setMode = useEditor((state) => state.setMode); const isSelected = selectedZoneId === zone.id; const isHovered = hoveredId === zone.id; const itemRef = useRef(null); useEffect(() => { if (isSelected && itemRef.current) { itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } }, [isSelected]); const area = calculatePolygonArea(zone.polygon).toFixed(1); const defaultName = `Zone (${area}m²)`; const handleClick = () => { setSelection({ zoneId: zone.id }); setPhase("structure"); setMode("select"); }; const handleDoubleClick = () => { setIsEditing(true); }; const handleDelete = (e: React.MouseEvent) => { e.stopPropagation(); deleteNode(zone.id); if (isSelected) { setSelection({ zoneId: null }); } }; const handleColorChange = (color: string) => { updateNode(zone.id, { color }); }; return (
setHoveredId(zone.id)} onMouseLeave={() => setHoveredId(null)} > {/* Vertical tree line */}
{/* Horizontal branch line */}
setIsEditing(false)} onStartEditing={() => setIsEditing(true)} defaultName={defaultName} />
{/* Camera snapshot button */} e.stopPropagation()} >
{zone.camera && ( )} {zone.camera && ( )}
); } function MultiSelectionBadge() { const selectedIds = useViewer((state) => state.selection.selectedIds); const setSelection = useViewer((state) => state.setSelection); if (selectedIds.length <= 1) return null; return (
{selectedIds.length} objects selected
); } function ContentSection() { const nodes = useScene((state) => state.nodes); const selectedLevelId = useViewer((state) => state.selection.levelId); const structureLayer = useEditor((state) => state.structureLayer); const phase = useEditor((state) => state.phase); const setPhase = useEditor((state) => state.setPhase); const setMode = useEditor((state) => state.setMode); const setTool = useEditor((state) => state.setTool); const level = selectedLevelId ? (nodes[selectedLevelId] as LevelNode) : null; if (!level) { return (
Select a level to view content
); } if (structureLayer === "zones") { // Show zones for this level const levelZones = Object.values(nodes).filter( (node): node is ZoneNode => node.type === "zone" && node.parentId === selectedLevelId ); const handleAddZone = () => { setPhase("structure"); setMode("build"); setTool("zone"); }; if (levelZones.length === 0) { return (
No zones on this level.{" "}
); } return (
{levelZones.map((zone, index) => ( ))}
); } // Filter elements based on phase const elementChildren = level.children.filter((childId) => { const childNode = nodes[childId]; if (!childNode || childNode.type === "zone") return false; // We no longer filter out structural nodes in furnish mode or furnish nodes in structure mode // This allows nested items (like lights in a ceiling or cabinetry on a wall) to remain visible // and selectable in both modes, ensuring seamless transition in the tree view. return true; }); if (elementChildren.length === 0) { return (
No elements on this level
); } return (
{elementChildren.map((childId, index) => ( ))}
); } function BuildingItem({ building, isBuildingActive, buildingCameraOpen, setBuildingCameraOpen, }: { building: BuildingNode; isBuildingActive: boolean; buildingCameraOpen: string | null; setBuildingCameraOpen: (id: string | null) => void; }) { const setSelection = useViewer((state) => state.setSelection); const phase = useEditor((state) => state.phase); const setPhase = useEditor((state) => state.setPhase); const updateNode = useScene((state) => state.updateNode); const itemRef = useRef(null); useEffect(() => { if (isBuildingActive && itemRef.current) { itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } }, [isBuildingActive]); return ( setBuildingCameraOpen(open ? building.id : null)} > e.stopPropagation()} >
{building.camera && ( )} {building.camera && ( )}
{/* Tools and content for the active building */} {isBuildingActive && (
)}
); } export function SitePanel() { const nodes = useScene((state) => state.nodes); const rootNodeIds = useScene((state) => state.rootNodeIds); const updateNode = useScene((state) => state.updateNode); const selectedBuildingId = useViewer((state) => state.selection.buildingId); const setSelection = useViewer((state) => state.setSelection); const phase = useEditor((state) => state.phase); const setPhase = useEditor((state) => state.setPhase); const [siteCameraOpen, setSiteCameraOpen] = useState(false); const [buildingCameraOpen, setBuildingCameraOpen] = useState(null); const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null; const buildings = (siteNode?.type === 'site' ? siteNode.children : []) .map((child) => { const id = typeof child === 'string' ? child : child.id; return nodes[id] as BuildingNode | undefined; }) .filter((node): node is BuildingNode => node?.type === "building"); return (
{/* Site Header */} {siteNode && ( setPhase("site")} >
Site {siteNode.name || "Site"}
)} {/* When phase is site, show property line immediately under site header */} {phase === "site" && ( )} {/* Buildings List */} {buildings.length === 0 ? ( No buildings yet ) : ( {buildings.map((building) => { const isBuildingActive = (phase === "structure" || phase === "furnish") && selectedBuildingId === building.id; return ( ); })} )}
); }