rename items

This commit is contained in:
wass08
2026-01-29 15:37:51 +09:00
parent 9c185c4d33
commit 07dee4aefc
14 changed files with 543 additions and 240 deletions
-27
View File
@@ -29,9 +29,6 @@ export default function Editor() {
return ( return (
<div className="w-full h-full"> <div className="w-full h-full">
{/* <LevelModeSwitcher /> */}
<TestUndo />
<ActionMenu /> <ActionMenu />
<ReferencePanel /> <ReferencePanel />
<RoofPanel /> <RoofPanel />
@@ -52,30 +49,6 @@ export default function Editor() {
) )
} }
const TestUndo = () => {
const { undo, redo, futureStates, pastStates } = useScene.temporal.getState()
return (
<div className="absolute top-4 right-4 z-10 flex gap-2">
<button
className="px-4 py-2 rounded bg-white"
onClick={() => {
undo()
}}
>
Undo
</button>
<button
className="px-4 py-2 rounded bg-white"
onClick={() => {
redo()
}}
>
Redo
</button>
</div>
)
}
const Grid = ({ const Grid = ({
cellSize = 0.5, cellSize = 0.5,
@@ -2,7 +2,8 @@
import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core' import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Home, X } from 'lucide-react' import { X } from 'lucide-react'
import Image from 'next/image'
import { useCallback } from 'react' import { useCallback } from 'react'
export function RoofPanel() { export function RoofPanel() {
@@ -40,7 +41,7 @@ export function RoofPanel() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between gap-2 border-b p-3"> <div className="flex items-center justify-between gap-2 border-b p-3">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<Home className="h-4 w-4 shrink-0 text-muted-foreground" /> <Image src="/icons/roof.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
<h2 className="font-semibold text-foreground text-sm truncate"> <h2 className="font-semibold text-foreground text-sm truncate">
{node.name || 'Gable Roof'} {node.name || 'Gable Roof'}
</h2> </h2>
@@ -1,7 +1,8 @@
import { CeilingNode } from "@pascal-app/core"; import { CeilingNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from "@pascal-app/viewer";
import { Square } from "lucide-react"; import Image from "next/image";
import { useState } from "react"; import { useState } from "react";
import { RenamePopover } from "./rename-popover";
import { TreeNode, TreeNodeWrapper } from "./tree-node"; import { TreeNode, TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions"; import { TreeNodeActions } from "./tree-node-actions";
@@ -12,6 +13,7 @@ interface CeilingTreeNodeProps {
export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) { export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id)); const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
const isHovered = useViewer((state) => state.hoveredId === node.id); const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection); const setSelection = useViewer((state) => state.setSelection);
@@ -21,6 +23,10 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
setSelection({ selectedIds: [node.id] }); setSelection({ selectedIds: [node.id] });
}; };
const handleDoubleClick = () => {
setRenameOpen(true);
};
const handleMouseEnter = () => { const handleMouseEnter = () => {
setHoveredId(node.id); setHoveredId(node.id);
}; };
@@ -31,16 +37,24 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
// Calculate approximate area from polygon // Calculate approximate area from polygon
const area = calculatePolygonArea(node.polygon).toFixed(1); const area = calculatePolygonArea(node.polygon).toFixed(1);
const defaultName = `Ceiling (${area}m²)`;
return ( return (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper <TreeNodeWrapper
icon={<Square className="w-3.5 h-3.5" />} icon={<Image src="/icons/ceiling.png" alt="" width={14} height={14} className="object-contain" />}
label={node.name || `Ceiling (${area}m²)`} label={node.name || defaultName}
depth={depth} depth={depth}
hasChildren={node.children.length > 0} hasChildren={node.children.length > 0}
expanded={expanded} expanded={expanded}
onToggle={() => setExpanded(!expanded)} onToggle={() => setExpanded(!expanded)}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
isSelected={isSelected} isSelected={isSelected}
@@ -51,6 +65,7 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
<TreeNode key={childId} nodeId={childId} depth={depth + 1} /> <TreeNode key={childId} nodeId={childId} depth={depth + 1} />
))} ))}
</TreeNodeWrapper> </TreeNodeWrapper>
</RenamePopover>
); );
} }
@@ -8,7 +8,6 @@ import { useViewer } from "@pascal-app/viewer";
import { import {
Building2, Building2,
ChevronDown, ChevronDown,
Hexagon,
Layers, Layers,
MoreHorizontal, MoreHorizontal,
Plus, Plus,
@@ -19,6 +18,7 @@ import { cn } from "@/lib/utils";
import useEditor from "@/store/use-editor"; import useEditor from "@/store/use-editor";
import { TreeNode } from "./tree-node"; import { TreeNode } from "./tree-node";
import { ReferencesDialog } from "./references-dialog"; import { ReferencesDialog } from "./references-dialog";
import { RenamePopover } from "./rename-popover";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -293,7 +293,20 @@ function LayerToggle() {
); );
} }
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 ZoneItem({ zone }: { zone: ZoneNode }) { function ZoneItem({ zone }: { zone: ZoneNode }) {
const [renameOpen, setRenameOpen] = useState(false);
const deleteNode = useScene((state) => state.deleteNode); const deleteNode = useScene((state) => state.deleteNode);
const updateNode = useScene((state) => state.updateNode); const updateNode = useScene((state) => state.updateNode);
const selectedZoneId = useViewer((state) => state.selection.zoneId); const selectedZoneId = useViewer((state) => state.selection.zoneId);
@@ -306,12 +319,19 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
const isSelected = selectedZoneId === zone.id; const isSelected = selectedZoneId === zone.id;
const isHovered = hoveredId === zone.id; const isHovered = hoveredId === zone.id;
const area = calculatePolygonArea(zone.polygon).toFixed(1);
const defaultName = `Zone (${area}m²)`;
const handleClick = () => { const handleClick = () => {
setSelection({ zoneId: zone.id }); setSelection({ zoneId: zone.id });
setPhase("structure"); setPhase("structure");
setMode("select"); setMode("select");
}; };
const handleDoubleClick = () => {
setRenameOpen(true);
};
const handleDelete = (e: React.MouseEvent) => { const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
deleteNode(zone.id); deleteNode(zone.id);
@@ -325,9 +345,15 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
}; };
return ( return (
<RenamePopover
node={zone}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<div <div
className={cn( className={cn(
"flex items-center h-7 cursor-pointer group/row text-sm px-3", "flex items-center h-7 cursor-pointer group/row text-sm px-3 select-none",
isSelected isSelected
? "text-primary-foreground bg-primary/80 hover:bg-primary/90" ? "text-primary-foreground bg-primary/80 hover:bg-primary/90"
: isHovered : isHovered
@@ -335,6 +361,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
: "text-muted-foreground hover:bg-accent/50" : "text-muted-foreground hover:bg-accent/50"
)} )}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={() => setHoveredId(zone.id)} onMouseEnter={() => setHoveredId(zone.id)}
onMouseLeave={() => setHoveredId(null)} onMouseLeave={() => setHoveredId(null)}
> >
@@ -366,8 +393,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<Hexagon className="w-3.5 h-3.5 mr-1.5 shrink-0" /> <span className="truncate flex-1">{zone.name || defaultName}</span>
<span className="truncate flex-1">{zone.name}</span>
<button <button
className="opacity-0 group-hover/row:opacity-100 w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20" className="opacity-0 group-hover/row:opacity-100 w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
onClick={handleDelete} onClick={handleDelete}
@@ -375,6 +401,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>
</div> </div>
</RenamePopover>
); );
} }
@@ -432,8 +459,13 @@ function ContentSection() {
); );
} }
// Show elements (walls, items, etc.) for this level // Show elements (walls, items, etc.) for this level - filter out zones
if (level.children.length === 0) { const elementChildren = level.children.filter((childId) => {
const childNode = nodes[childId];
return childNode && childNode.type !== "zone";
});
if (elementChildren.length === 0) {
return ( return (
<div className="px-3 py-4 text-sm text-muted-foreground"> <div className="px-3 py-4 text-sm text-muted-foreground">
No elements on this level No elements on this level
@@ -443,7 +475,7 @@ function ContentSection() {
return ( return (
<div className="py-1"> <div className="py-1">
{level.children.map((childId) => ( {elementChildren.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={0} /> <TreeNode key={childId} nodeId={childId} depth={0} />
))} ))}
</div> </div>
@@ -1,15 +1,29 @@
import { ItemNode } from "@pascal-app/core"; import { ItemNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from "@pascal-app/viewer";
import { Box } from "lucide-react"; import Image from "next/image";
import { useState } from "react";
import { RenamePopover } from "./rename-popover";
import { TreeNodeWrapper } from "./tree-node"; import { TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions"; import { TreeNodeActions } from "./tree-node-actions";
const CATEGORY_ICONS: Record<string, string> = {
door: "/icons/door.png",
window: "/icons/window.png",
furniture: "/icons/couch.png",
appliance: "/icons/appliance.png",
kitchen: "/icons/kitchen.png",
bathroom: "/icons/bathroom.png",
outdoor: "/icons/tree.png",
};
interface ItemTreeNodeProps { interface ItemTreeNodeProps {
node: ItemNode; node: ItemNode;
depth: number; depth: number;
} }
export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) { export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
const [renameOpen, setRenameOpen] = useState(false);
const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png";
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id)); const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
const isHovered = useViewer((state) => state.hoveredId === node.id); const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection); const setSelection = useViewer((state) => state.setSelection);
@@ -19,6 +33,10 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
setSelection({ selectedIds: [node.id] }); setSelection({ selectedIds: [node.id] });
}; };
const handleDoubleClick = () => {
setRenameOpen(true);
};
const handleMouseEnter = () => { const handleMouseEnter = () => {
setHoveredId(node.id); setHoveredId(node.id);
}; };
@@ -27,20 +45,30 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
setHoveredId(null); setHoveredId(null);
}; };
const defaultName = node.asset.name || "Item";
return ( return (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper <TreeNodeWrapper
icon={<Box className="w-3.5 h-3.5" />} icon={<Image src={iconSrc} alt="" width={14} height={14} className="object-contain" />}
label={node.name || node.asset.name || "Item"} label={node.name || defaultName}
depth={depth} depth={depth}
hasChildren={false} hasChildren={false}
expanded={false} expanded={false}
onToggle={() => {}} onToggle={() => {}}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
isSelected={isSelected} isSelected={isSelected}
isHovered={isHovered} isHovered={isHovered}
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions node={node} />}
/> />
</RenamePopover>
); );
} }
@@ -2,6 +2,7 @@ import { LevelNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from "@pascal-app/viewer";
import { Layers } from "lucide-react"; import { Layers } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { RenamePopover } from "./rename-popover";
import { TreeNode, TreeNodeWrapper } from "./tree-node"; import { TreeNode, TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions"; import { TreeNodeActions } from "./tree-node-actions";
@@ -12,6 +13,7 @@ interface LevelTreeNodeProps {
export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) { export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) {
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const [renameOpen, setRenameOpen] = useState(false);
const isSelected = useViewer((state) => state.selection.levelId === node.id); const isSelected = useViewer((state) => state.selection.levelId === node.id);
const isHovered = useViewer((state) => state.hoveredId === node.id); const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection); const setSelection = useViewer((state) => state.setSelection);
@@ -20,15 +22,28 @@ export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) {
setSelection({ levelId: node.id }); setSelection({ levelId: node.id });
}; };
const handleDoubleClick = () => {
setRenameOpen(true);
};
const defaultName = `Level ${node.level}`;
return ( return (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper <TreeNodeWrapper
icon={<Layers className="w-3.5 h-3.5" />} icon={<Layers className="w-3.5 h-3.5" />}
label={node.name || `Level ${node.level}`} label={node.name || defaultName}
depth={depth} depth={depth}
hasChildren={node.children.length > 0} hasChildren={node.children.length > 0}
expanded={expanded} expanded={expanded}
onToggle={() => setExpanded(!expanded)} onToggle={() => setExpanded(!expanded)}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick}
isSelected={isSelected} isSelected={isSelected}
isHovered={isHovered} isHovered={isHovered}
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions node={node} />}
@@ -37,5 +52,6 @@ export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) {
<TreeNode key={childId} nodeId={childId} depth={depth + 1} /> <TreeNode key={childId} nodeId={childId} depth={depth + 1} />
))} ))}
</TreeNodeWrapper> </TreeNodeWrapper>
</RenamePopover>
); );
} }
@@ -0,0 +1,94 @@
import { useScene, type AnyNode } from "@pascal-app/core";
import { Check, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/primitives/popover";
interface RenamePopoverProps {
node: AnyNode;
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
defaultName: string;
}
export function RenamePopover({
node,
open,
onOpenChange,
children,
defaultName,
}: RenamePopoverProps) {
const updateNode = useScene((s) => s.updateNode);
const [value, setValue] = useState(node.name || "");
const inputRef = useRef<HTMLInputElement>(null);
// Reset value when popover opens
useEffect(() => {
if (open) {
setValue(node.name || "");
// Focus and select all text after a short delay
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
}
}, [open, node.name]);
const handleSave = useCallback(() => {
const trimmed = value.trim();
// Only update if name actually changed
if (trimmed !== node.name) {
updateNode(node.id, { name: trimmed || undefined });
}
onOpenChange(false);
}, [value, node.id, node.name, updateNode, onOpenChange]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
e.preventDefault();
onOpenChange(false);
}
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverAnchor asChild>{children}</PopoverAnchor>
<PopoverContent
className="w-64 p-2 z-50"
align="start"
side="right"
sideOffset={8}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex items-center gap-1">
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={defaultName}
className="flex-1 rounded border border-input bg-background px-2 py-1 text-sm outline-none focus:border-primary"
/>
<button
type="button"
className="w-7 h-7 flex items-center justify-center rounded hover:bg-accent text-muted-foreground hover:text-foreground"
onClick={handleSave}
>
<Check className="w-4 h-4" />
</button>
<button
type="button"
className="w-7 h-7 flex items-center justify-center rounded hover:bg-accent text-muted-foreground hover:text-foreground"
onClick={() => onOpenChange(false)}
>
<X className="w-4 h-4" />
</button>
</div>
</PopoverContent>
</Popover>
);
}
@@ -1,6 +1,8 @@
import { RoofNode } from "@pascal-app/core"; import { RoofNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from "@pascal-app/viewer";
import { Home } from "lucide-react"; import Image from "next/image";
import { useState } from "react";
import { RenamePopover } from "./rename-popover";
import { TreeNodeWrapper } from "./tree-node"; import { TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions"; import { TreeNodeActions } from "./tree-node-actions";
@@ -10,6 +12,7 @@ interface RoofTreeNodeProps {
} }
export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) { export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
const [renameOpen, setRenameOpen] = useState(false);
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id)); const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
const isHovered = useViewer((state) => state.hoveredId === node.id); const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection); const setSelection = useViewer((state) => state.setSelection);
@@ -19,6 +22,10 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
setSelection({ selectedIds: [node.id] }); setSelection({ selectedIds: [node.id] });
}; };
const handleDoubleClick = () => {
setRenameOpen(true);
};
const handleMouseEnter = () => { const handleMouseEnter = () => {
setHoveredId(node.id); setHoveredId(node.id);
}; };
@@ -30,21 +37,30 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
// Calculate dimensions: length × total width (leftWidth + rightWidth) // Calculate dimensions: length × total width (leftWidth + rightWidth)
const totalWidth = node.leftWidth + node.rightWidth; const totalWidth = node.leftWidth + node.rightWidth;
const sizeLabel = `${node.length.toFixed(1)}×${totalWidth.toFixed(1)}m`; const sizeLabel = `${node.length.toFixed(1)}×${totalWidth.toFixed(1)}m`;
const defaultName = `Roof (${sizeLabel})`;
return ( return (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper <TreeNodeWrapper
icon={<Home className="w-3.5 h-3.5" />} icon={<Image src="/icons/roof.png" alt="" width={14} height={14} className="object-contain" />}
label={node.name || `Roof (${sizeLabel})`} label={node.name || defaultName}
depth={depth} depth={depth}
hasChildren={false} hasChildren={false}
expanded={false} expanded={false}
onToggle={() => {}} onToggle={() => {}}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
isSelected={isSelected} isSelected={isSelected}
isHovered={isHovered} isHovered={isHovered}
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions node={node} />}
/> />
</RenamePopover>
); );
} }
@@ -1,6 +1,8 @@
import { SlabNode } from "@pascal-app/core"; import { SlabNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from "@pascal-app/viewer";
import { RectangleHorizontal } from "lucide-react"; import Image from "next/image";
import { useState } from "react";
import { RenamePopover } from "./rename-popover";
import { TreeNodeWrapper } from "./tree-node"; import { TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions"; import { TreeNodeActions } from "./tree-node-actions";
@@ -10,6 +12,7 @@ interface SlabTreeNodeProps {
} }
export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) { export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
const [renameOpen, setRenameOpen] = useState(false);
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id)); const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
const isHovered = useViewer((state) => state.hoveredId === node.id); const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection); const setSelection = useViewer((state) => state.setSelection);
@@ -19,6 +22,10 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
setSelection({ selectedIds: [node.id] }); setSelection({ selectedIds: [node.id] });
}; };
const handleDoubleClick = () => {
setRenameOpen(true);
};
const handleMouseEnter = () => { const handleMouseEnter = () => {
setHoveredId(node.id); setHoveredId(node.id);
}; };
@@ -29,22 +36,31 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
// Calculate approximate area from polygon // Calculate approximate area from polygon
const area = calculatePolygonArea(node.polygon).toFixed(1); const area = calculatePolygonArea(node.polygon).toFixed(1);
const defaultName = `Slab (${area}m²)`;
return ( return (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper <TreeNodeWrapper
icon={<RectangleHorizontal className="w-3.5 h-3.5" />} icon={<Image src="/icons/floor.png" alt="" width={14} height={14} className="object-contain" />}
label={node.name || `Slab (${area}m²)`} label={node.name || defaultName}
depth={depth} depth={depth}
hasChildren={false} hasChildren={false}
expanded={false} expanded={false}
onToggle={() => {}} onToggle={() => {}}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
isSelected={isSelected} isSelected={isSelected}
isHovered={isHovered} isHovered={isHovered}
actions={<TreeNodeActions node={node} />} actions={<TreeNodeActions node={node} />}
/> />
</RenamePopover>
); );
} }
@@ -1,5 +1,6 @@
import { AnyNodeId, useScene } from "@pascal-app/core"; import { AnyNodeId, useScene } from "@pascal-app/core";
import { ChevronDown, ChevronRight } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide-react";
import { forwardRef } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BuildingTreeNode } from "./building-tree-node"; import { BuildingTreeNode } from "./building-tree-node";
import { CeilingTreeNode } from "./ceiling-tree-node"; import { CeilingTreeNode } from "./ceiling-tree-node";
@@ -8,6 +9,7 @@ import { LevelTreeNode } from "./level-tree-node";
import { RoofTreeNode } from "./roof-tree-node"; import { RoofTreeNode } from "./roof-tree-node";
import { SlabTreeNode } from "./slab-tree-node"; import { SlabTreeNode } from "./slab-tree-node";
import { WallTreeNode } from "./wall-tree-node"; import { WallTreeNode } from "./wall-tree-node";
import { ZoneTreeNode } from "./zone-tree-node";
interface TreeNodeProps { interface TreeNodeProps {
nodeId: AnyNodeId; nodeId: AnyNodeId;
@@ -34,6 +36,8 @@ export function TreeNode({ nodeId, depth = 0 }: TreeNodeProps) {
return <RoofTreeNode node={node} depth={depth} />; return <RoofTreeNode node={node} depth={depth} />;
case "item": case "item":
return <ItemTreeNode node={node} depth={depth} />; return <ItemTreeNode node={node} depth={depth} />;
case "zone":
return <ZoneTreeNode node={node} depth={depth} />;
default: default:
return null; return null;
} }
@@ -47,6 +51,7 @@ interface TreeNodeWrapperProps {
expanded: boolean; expanded: boolean;
onToggle: () => void; onToggle: () => void;
onClick: () => void; onClick: () => void;
onDoubleClick?: () => void;
onMouseEnter?: () => void; onMouseEnter?: () => void;
onMouseLeave?: () => void; onMouseLeave?: () => void;
actions?: React.ReactNode; actions?: React.ReactNode;
@@ -55,7 +60,9 @@ interface TreeNodeWrapperProps {
isHovered?: boolean; isHovered?: boolean;
} }
export function TreeNodeWrapper({ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
function TreeNodeWrapper(
{
icon, icon,
label, label,
depth, depth,
@@ -63,18 +70,21 @@ export function TreeNodeWrapper({
expanded, expanded,
onToggle, onToggle,
onClick, onClick,
onDoubleClick,
onMouseEnter, onMouseEnter,
onMouseLeave, onMouseLeave,
actions, actions,
children, children,
isSelected, isSelected,
isHovered, isHovered,
}: TreeNodeWrapperProps) { },
ref
) {
return ( return (
<div> <div ref={ref}>
<div <div
className={cn( className={cn(
"flex items-center h-7 cursor-pointer group/row text-sm", "flex items-center h-7 cursor-pointer group/row text-sm select-none",
isSelected isSelected
? "text-primary-foreground bg-primary/80 hover:bg-primary/90" ? "text-primary-foreground bg-primary/80 hover:bg-primary/90"
: isHovered : isHovered
@@ -103,6 +113,7 @@ export function TreeNodeWrapper({
<div <div
className="flex items-center gap-1.5 flex-1 min-w-0" className="flex items-center gap-1.5 flex-1 min-w-0"
onClick={onClick} onClick={onClick}
onDoubleClick={onDoubleClick}
> >
<span className="w-4 h-4 flex items-center justify-center shrink-0"> <span className="w-4 h-4 flex items-center justify-center shrink-0">
{icon} {icon}
@@ -119,3 +130,4 @@ export function TreeNodeWrapper({
</div> </div>
); );
} }
);
@@ -1,7 +1,8 @@
import { WallNode } from "@pascal-app/core"; import { WallNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from "@pascal-app/viewer";
import { Square } from "lucide-react"; import Image from "next/image";
import { useState } from "react"; import { useState } from "react";
import { RenamePopover } from "./rename-popover";
import { TreeNode, TreeNodeWrapper } from "./tree-node"; import { TreeNode, TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions"; import { TreeNodeActions } from "./tree-node-actions";
@@ -12,6 +13,7 @@ interface WallTreeNodeProps {
export function WallTreeNode({ node, depth }: WallTreeNodeProps) { export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id)); const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
const isHovered = useViewer((state) => state.hoveredId === node.id); const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection); const setSelection = useViewer((state) => state.setSelection);
@@ -21,6 +23,10 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
setSelection({ selectedIds: [node.id] }); setSelection({ selectedIds: [node.id] });
}; };
const handleDoubleClick = () => {
setRenameOpen(true);
};
const handleMouseEnter = () => { const handleMouseEnter = () => {
setHoveredId(node.id); setHoveredId(node.id);
}; };
@@ -34,15 +40,24 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
Math.pow(node.end[1] - node.start[1], 2), Math.pow(node.end[1] - node.start[1], 2),
).toFixed(1); ).toFixed(1);
const defaultName = `Wall (${wallLength}m/${node.height || 2.5}m)`;
return ( return (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper <TreeNodeWrapper
icon={<Square className="w-3.5 h-3.5" />} icon={<Image src="/icons/wall.png" alt="" width={14} height={14} className="object-contain" />}
label={node.name || `Wall (${wallLength}m/${node.height || 2.5}m)`} label={node.name || defaultName}
depth={depth} depth={depth}
hasChildren={node.children.length > 0} hasChildren={node.children.length > 0}
expanded={expanded} expanded={expanded}
onToggle={() => setExpanded(!expanded)} onToggle={() => setExpanded(!expanded)}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
isSelected={isSelected} isSelected={isSelected}
@@ -53,5 +68,6 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
<TreeNode key={childId} nodeId={childId} depth={depth + 1} /> <TreeNode key={childId} nodeId={childId} depth={depth + 1} />
))} ))}
</TreeNodeWrapper> </TreeNodeWrapper>
</RenamePopover>
); );
} }
@@ -0,0 +1,87 @@
import { ZoneNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { useState } from "react";
import { RenamePopover } from "./rename-popover";
import { TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions";
interface ZoneTreeNodeProps {
node: ZoneNode;
depth: number;
}
export function ZoneTreeNode({ node, depth }: ZoneTreeNodeProps) {
const [renameOpen, setRenameOpen] = useState(false);
const isSelected = useViewer((state) => state.selection.zoneId === node.id);
const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection);
const setHoveredId = useViewer((state) => state.setHoveredId);
const handleClick = () => {
setSelection({ zoneId: node.id });
};
const handleDoubleClick = () => {
setRenameOpen(true);
};
const handleMouseEnter = () => {
setHoveredId(node.id);
};
const handleMouseLeave = () => {
setHoveredId(null);
};
// Calculate approximate area from polygon
const area = calculatePolygonArea(node.polygon).toFixed(1);
const defaultName = `Zone (${area}m²)`;
return (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper
icon={
<div
className="w-3 h-3 rounded-sm border border-border/50"
style={{ backgroundColor: node.color }}
/>
}
label={node.name || defaultName}
depth={depth}
hasChildren={false}
expanded={false}
onToggle={() => {}}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
/>
</RenamePopover>
);
}
/**
* Calculate the area of a polygon using the shoelace formula
*/
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;
}
@@ -210,7 +210,6 @@ export class SpatialGridManager {
// Called when nodes change // Called when nodes change
handleNodeCreated(node: AnyNode, levelId: string) { handleNodeCreated(node: AnyNode, levelId: string) {
if (node.type === 'slab') { if (node.type === 'slab') {
console.log(`[SpatialGrid] Adding slab ${node.id} to level ${levelId}`, (node as SlabNode).polygon)
this.getSlabMap(levelId).set(node.id, node as SlabNode) this.getSlabMap(levelId).set(node.id, node as SlabNode)
} else if (node.type === 'ceiling') { } else if (node.type === 'ceiling') {
this.ceilings.set(node.id, node as CeilingNode) this.ceilings.set(node.id, node as CeilingNode)
@@ -459,7 +458,6 @@ export class SpatialGridManager {
end: [number, number], end: [number, number],
): number { ): number {
const slabMap = this.slabsByLevel.get(levelId) const slabMap = this.slabsByLevel.get(levelId)
console.log(`[SpatialGrid] getSlabElevationForWall | levelId: ${levelId} | slabMap size: ${slabMap?.size ?? 0} | slabs:`, slabMap ? Array.from(slabMap.keys()) : [])
if (!slabMap) return 0 if (!slabMap) return 0
let maxElevation = 0 let maxElevation = 0
@@ -107,7 +107,6 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
const levelId = resolveLevelId(node, nodes) const levelId = resolveLevelId(node, nodes)
const slabElevation = spatialGridManager.getSlabElevationForWall(levelId, node.start, node.end) const slabElevation = spatialGridManager.getSlabElevationForWall(levelId, node.start, node.end)
console.log(`[WallSystem] Wall ${node.name || node.id} | levelId: ${levelId} | start: [${node.start}] | end: [${node.end}] | slabElevation: ${slabElevation}`)
const childrenIds = node.children || [] const childrenIds = node.children || []
const childrenNodes = childrenIds const childrenNodes = childrenIds