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 */}
{/* 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 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) => (
setSelection({ buildingId: building.id })}
>
{building.name || "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 (
{selectedBuilding?.name || "Select Building"}
{buildings.map((building) => (
{
setSelection({ buildingId: building.id });
// Also select first level if available
if (building.children.length > 0) {
setSelection({ levelId: building.children[0] as LevelNode["id"] });
}
}}
>
{building.name || "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 */}
{/* Level buttons */}
{levels.map((level) => (
setSelection({ levelId: level.id })}
>
{level.name || `Level ${level.level}`}
{/* Camera snapshot button */}
setCameraPopoverOpen(open ? level.id : null)}>
e.stopPropagation()}
title="Camera snapshot"
>
{level.camera && (
)}
e.stopPropagation()}
>
{level.camera && (
{
e.stopPropagation();
emitter.emit("camera-controls:view", { nodeId: level.id });
setCameraPopoverOpen(null);
}}
>
View snapshot
)}
{
e.stopPropagation();
emitter.emit("camera-controls:capture", { nodeId: level.id });
setCameraPopoverOpen(null);
}}
>
{level.camera ? "Update snapshot" : "Take snapshot"}
{level.camera && (
{
e.stopPropagation();
updateNode(level.id, { camera: undefined });
setCameraPopoverOpen(null);
}}
>
Clear snapshot
)}
e.stopPropagation()}
>
setReferencesLevelId(level.id)}
>
References
{level.level !== 0 && (
deleteNode(level.id)}
>
Delete
)}
))}
{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 (
setStructureLayer("elements")}
>
Elements
setStructureLayer("zones")}
>
Zones
);
}
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)}
>
e.stopPropagation()}
style={{ backgroundColor: zone.color }}
/>
e.stopPropagation()}
>
{PRESET_COLORS.map((color) => (
handleColorChange(color)}
style={{ backgroundColor: color }}
/>
))}
{zone.name || 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 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) => (
))}
);
}
// 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 ;
}