import { type BuildingNode, emitter, LevelNode, type SiteNode, useScene, type ZoneNode, } from "@pascal-app/core"; import { useViewer } from "@pascal-app/viewer"; import { Building2, Camera, ChevronDown, Layers, MapPin, MoreHorizontal, Pencil, Plus, Trash2, } from "lucide-react"; import { useState } from "react"; import { cn } from "@/lib/utils"; import useEditor from "@/store/use-editor"; import { TreeNode } from "./tree-node"; import { ReferencesDialog } from "./references-dialog"; import { RenamePopover } from "./rename-popover"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/primitives/popover"; // Preset colors for zones const PRESET_COLORS = [ "#3b82f6", // blue "#22c55e", // green "#eab308", // yellow "#f97316", // orange "#ef4444", // red "#a855f7", // purple "#ec4899", // pink "#06b6d4", // cyan ]; // ============================================================================ // 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 (
{/* Header */}
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 SitePhaseView() { const nodes = useScene((state) => state.nodes); const rootNodeIds = useScene((state) => state.rootNodeIds); const selectedBuildingId = useViewer((state) => state.selection.buildingId); const setSelection = useViewer((state) => state.setSelection); // Get site node and its building children const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null; const buildings = (siteNode?.type === 'site' ? siteNode.children : []) .map((child) => typeof child === 'string' ? nodes[child] : child) .filter((node): node is BuildingNode => node?.type === "building"); return (
{buildings.length === 0 ? (
No buildings yet
) : (
{buildings.map((building) => ( ))}
)}
); } // ============================================================================ // STRUCTURE/FURNISH PHASE VIEW - Building dropdown + Levels + Content // ============================================================================ function BuildingSelector() { const nodes = useScene((state) => state.nodes); const rootNodeIds = useScene((state) => state.rootNodeIds); const selectedBuildingId = useViewer((state) => state.selection.buildingId); const setSelection = useViewer((state) => state.setSelection); // Get site node and its building children const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null; const buildings = (siteNode?.type === 'site' ? siteNode.children : []) .map((child) => typeof child === 'string' ? nodes[child] : child) .filter((node): node is BuildingNode => node?.type === "building"); const selectedBuilding = selectedBuildingId ? (nodes[selectedBuildingId] as BuildingNode) : null; if (buildings.length === 0) return null; // If only one building, just show it as a header if (buildings.length === 1) { return (
{buildings[0]?.name || "Building"}
); } return ( {buildings.map((building) => ( ))} ); } 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 [referencesLevelId, setReferencesLevelId] = useState(null); const [cameraPopoverOpen, setCameraPopoverOpen] = useState(null); 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 (
{/* Header */}
Levels
{/* Level buttons */}
{levels.map((level) => (
{/* Camera snapshot button */} setCameraPopoverOpen(open ? level.id : null)}> e.stopPropagation()} >
{level.camera && ( )} {level.camera && ( )}
{level.level !== 0 && ( )}
))} {levels.length === 0 && (
No levels yet
)}
{/* References dialog */} {referencesLevelId && ( { if (!open) setReferencesLevelId(null); }} /> )}
); } function LayerToggle() { const structureLayer = useEditor((state) => state.structureLayer); const setStructureLayer = useEditor((state) => state.setStructureLayer); return (
); } function ZoneItem({ zone }: { zone: ZoneNode }) { const [renameOpen, setRenameOpen] = 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 area = calculatePolygonArea(zone.polygon).toFixed(1); const defaultName = `Zone (${area}m²)`; const handleClick = () => { setSelection({ zoneId: zone.id }); setPhase("structure"); setMode("select"); }; const handleDoubleClick = () => { setRenameOpen(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)} >
{zone.name || defaultName} {/* Camera snapshot button */} e.stopPropagation()} >
{zone.camera && ( )} {zone.camera && ( )}
); } 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) => ( ))}
); } // Filter elements based on phase const elementChildren = level.children.filter((childId) => { const childNode = nodes[childId]; if (!childNode || childNode.type === "zone") return false; // In structure mode, show structural elements (walls, slabs, etc.) and doors/windows if (phase === "structure") { if (childNode.type === "item") { const category = childNode.asset?.category?.toLowerCase() || ""; // Only show doors and windows in structure mode return category === "door" || category === "window"; } // Show all other structural elements (walls, slabs, ceiling, roof) return true; } // In furnish mode, only show items that are NOT doors or windows if (phase === "furnish") { if (childNode.type === "item") { const category = childNode.asset?.category?.toLowerCase() || ""; // Hide doors and windows in furnish mode return category !== "door" && category !== "window"; } // Hide structural elements in furnish mode return false; } return true; }); if (elementChildren.length === 0) { return (
No elements on this level
); } return (
{elementChildren.map((childId) => ( ))}
); } function StructurePhaseView() { const phase = useEditor((state) => state.phase); return (
{/* Only show layer toggle in structure phase, furnish is always elements */} {phase === "structure" && }
); } // ============================================================================ // MAIN SITE PANEL // ============================================================================ export function SitePanel() { const phase = useEditor((state) => state.phase); if (phase === "site") { return ; } return ; }