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 */}
{/* Measurements */}
Area: {area.toFixed(1)} m²
Perimeter:{" "}
{perimeter.toFixed(1)} m
{/* Vertex list (shown when editing) */}
{isEditing && (
)}
);
}
// ============================================================================
// 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()}
title="Camera snapshot"
>
{hasCamera && (
)}
e.stopPropagation()}
>
{hasCamera && (
{
e.stopPropagation();
emitter.emit("camera-controls:view", { nodeId });
onOpenChange(false);
}}
>
View snapshot
)}
{
e.stopPropagation();
emitter.emit("camera-controls:capture", { nodeId });
onOpenChange(false);
}}
>
{hasCamera ? "Update snapshot" : "Take snapshot"}
{hasCamera && (
{
e.stopPropagation();
updateNode(nodeId, { camera: undefined });
onOpenChange(false);
}}
>
Clear snapshot
)}
);
}
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' ?
:
}
setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={refNode.type === 'scan' ? '3D Scan' : 'Guide Image'}
/>
handleDelete(refNode.id, e)}
title="Delete"
>
);
}
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 && (
)}
);
}
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 && (
)}
{
e.stopPropagation();
if (!isSelected) {
setSelection({ levelId: level.id });
} else {
setIsExpanded(!isExpanded);
}
}}
>
{isExpanded ? (
) : (
)}
setSelection({ levelId: level.id })}
onDoubleClick={() => setIsEditing(true)}
>
setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={`Level ${level.level}`}
/>
{/* Camera snapshot button */}
e.stopPropagation()}
title="Camera snapshot"
>
{level.camera && (
)}
e.stopPropagation()}
>
{level.camera && (
{
e.stopPropagation();
emitter.emit("camera-controls:view", { nodeId: level.id });
setCameraPopoverOpen(false);
}}
>
View snapshot
)}
{
e.stopPropagation();
emitter.emit("camera-controls:capture", { nodeId: level.id });
setCameraPopoverOpen(false);
}}
>
{level.camera ? "Update snapshot" : "Take snapshot"}
{level.camera && (
{
e.stopPropagation();
updateNode(level.id, { camera: undefined });
setCameraPopoverOpen(false);
}}
>
Clear snapshot
)}
e.stopPropagation()}
>
{level.level !== 0 && (
deleteNode(level.id)}
>
Delete
)}
{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 */}
{/* Vertical tree line */}
{/* Horizontal branch line */}
Add level
{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 (
{
setPhase("structure");
setStructureLayer("elements");
}}
>
{activeTab === "structure" && (
)}
Structure
S
{
setPhase("furnish");
}}
>
{activeTab === "furnish" && (
)}
Furnish
F
{
setPhase("structure");
setStructureLayer("zones");
}}
>
{activeTab === "zones" && (
)}
Zones
Z
);
}
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()}
title="Camera snapshot"
>
{zone.camera && (
)}
e.stopPropagation()}
>
{zone.camera && (
{
e.stopPropagation();
emitter.emit("camera-controls:view", { nodeId: zone.id });
setCameraPopoverOpen(false);
}}
>
View snapshot
)}
{
e.stopPropagation();
emitter.emit("camera-controls:capture", { nodeId: zone.id });
setCameraPopoverOpen(false);
}}
>
{zone.camera ? "Update snapshot" : "Take snapshot"}
{zone.camera && (
{
e.stopPropagation();
updateNode(zone.id, { camera: undefined });
setCameraPopoverOpen(false);
}}
>
Clear snapshot
)}
);
}
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
setSelection({ selectedIds: [] })}
className="hover:bg-primary-foreground/20 p-1.5 rounded-full transition-colors cursor-pointer"
title="Clear selection"
>
);
}
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.{" "}
Add one
);
}
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 (
{
setSelection({ buildingId: building.id });
if (phase === "site") {
setPhase("structure");
}
}}
>
{building.name || "Building"}
setBuildingCameraOpen(open ? building.id : null)}
>
e.stopPropagation()}
title="Camera snapshot"
>
{building.camera && (
)}
e.stopPropagation()}
>
{building.camera && (
{
e.stopPropagation();
emitter.emit("camera-controls:view", { nodeId: building.id });
setBuildingCameraOpen(null);
}}
>
View snapshot
)}
{
e.stopPropagation();
emitter.emit("camera-controls:capture", { nodeId: building.id });
setBuildingCameraOpen(null);
}}
>
{building.camera ? "Update snapshot" : "Take snapshot"}
{building.camera && (
{
e.stopPropagation();
updateNode(building.id, { camera: undefined });
setBuildingCameraOpen(null);
}}
>
Clear snapshot
)}
{/* 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")}
>
{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 (
);
})}
)}
);
}