rename items
This commit is contained in:
@@ -29,9 +29,6 @@ export default function Editor() {
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
{/* <LevelModeSwitcher /> */}
|
||||
|
||||
<TestUndo />
|
||||
<ActionMenu />
|
||||
<ReferencePanel />
|
||||
<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 = ({
|
||||
cellSize = 0.5,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core'
|
||||
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'
|
||||
|
||||
export function RoofPanel() {
|
||||
@@ -40,7 +41,7 @@ export function RoofPanel() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2 border-b p-3">
|
||||
<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">
|
||||
{node.name || 'Gable Roof'}
|
||||
</h2>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { CeilingNode } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { Square } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { RenamePopover } from "./rename-popover";
|
||||
import { TreeNode, TreeNodeWrapper } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
|
||||
@@ -12,6 +13,7 @@ interface CeilingTreeNodeProps {
|
||||
|
||||
export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
@@ -21,6 +23,10 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
|
||||
setSelection({ selectedIds: [node.id] });
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setRenameOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id);
|
||||
};
|
||||
@@ -31,16 +37,24 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
|
||||
|
||||
// Calculate approximate area from polygon
|
||||
const area = calculatePolygonArea(node.polygon).toFixed(1);
|
||||
const defaultName = `Ceiling (${area}m²)`;
|
||||
|
||||
return (
|
||||
<RenamePopover
|
||||
node={node}
|
||||
open={renameOpen}
|
||||
onOpenChange={setRenameOpen}
|
||||
defaultName={defaultName}
|
||||
>
|
||||
<TreeNodeWrapper
|
||||
icon={<Square className="w-3.5 h-3.5" />}
|
||||
label={node.name || `Ceiling (${area}m²)`}
|
||||
icon={<Image src="/icons/ceiling.png" alt="" width={14} height={14} className="object-contain" />}
|
||||
label={node.name || defaultName}
|
||||
depth={depth}
|
||||
hasChildren={node.children.length > 0}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
isSelected={isSelected}
|
||||
@@ -51,6 +65,7 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
</RenamePopover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useViewer } from "@pascal-app/viewer";
|
||||
import {
|
||||
Building2,
|
||||
ChevronDown,
|
||||
Hexagon,
|
||||
Layers,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
@@ -19,6 +18,7 @@ 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,
|
||||
@@ -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 }) {
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const deleteNode = useScene((state) => state.deleteNode);
|
||||
const updateNode = useScene((state) => state.updateNode);
|
||||
const selectedZoneId = useViewer((state) => state.selection.zoneId);
|
||||
@@ -306,12 +319,19 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||
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);
|
||||
@@ -325,9 +345,15 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<RenamePopover
|
||||
node={zone}
|
||||
open={renameOpen}
|
||||
onOpenChange={setRenameOpen}
|
||||
defaultName={defaultName}
|
||||
>
|
||||
<div
|
||||
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
|
||||
? "text-primary-foreground bg-primary/80 hover:bg-primary/90"
|
||||
: isHovered
|
||||
@@ -335,6 +361,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||
: "text-muted-foreground hover:bg-accent/50"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={() => setHoveredId(zone.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
>
|
||||
@@ -366,8 +393,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Hexagon className="w-3.5 h-3.5 mr-1.5 shrink-0" />
|
||||
<span className="truncate flex-1">{zone.name}</span>
|
||||
<span className="truncate flex-1">{zone.name || defaultName}</span>
|
||||
<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"
|
||||
onClick={handleDelete}
|
||||
@@ -375,6 +401,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</RenamePopover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -432,8 +459,13 @@ function ContentSection() {
|
||||
);
|
||||
}
|
||||
|
||||
// Show elements (walls, items, etc.) for this level
|
||||
if (level.children.length === 0) {
|
||||
// Show elements (walls, items, etc.) for this level - filter out zones
|
||||
const elementChildren = level.children.filter((childId) => {
|
||||
const childNode = nodes[childId];
|
||||
return childNode && childNode.type !== "zone";
|
||||
});
|
||||
|
||||
if (elementChildren.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||
No elements on this level
|
||||
@@ -443,7 +475,7 @@ function ContentSection() {
|
||||
|
||||
return (
|
||||
<div className="py-1">
|
||||
{level.children.map((childId) => (
|
||||
{elementChildren.map((childId) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={0} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { ItemNode } from "@pascal-app/core";
|
||||
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 { 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 {
|
||||
node: ItemNode;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
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 isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
@@ -19,6 +33,10 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
||||
setSelection({ selectedIds: [node.id] });
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setRenameOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id);
|
||||
};
|
||||
@@ -27,20 +45,30 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
||||
setHoveredId(null);
|
||||
};
|
||||
|
||||
const defaultName = node.asset.name || "Item";
|
||||
|
||||
return (
|
||||
<RenamePopover
|
||||
node={node}
|
||||
open={renameOpen}
|
||||
onOpenChange={setRenameOpen}
|
||||
defaultName={defaultName}
|
||||
>
|
||||
<TreeNodeWrapper
|
||||
icon={<Box className="w-3.5 h-3.5" />}
|
||||
label={node.name || node.asset.name || "Item"}
|
||||
icon={<Image src={iconSrc} alt="" width={14} height={14} className="object-contain" />}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { LevelNode } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { Layers } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { RenamePopover } from "./rename-popover";
|
||||
import { TreeNode, TreeNodeWrapper } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
|
||||
@@ -12,6 +13,7 @@ interface LevelTreeNodeProps {
|
||||
|
||||
export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const isSelected = useViewer((state) => state.selection.levelId === node.id);
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
@@ -20,15 +22,28 @@ export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) {
|
||||
setSelection({ levelId: node.id });
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setRenameOpen(true);
|
||||
};
|
||||
|
||||
const defaultName = `Level ${node.level}`;
|
||||
|
||||
return (
|
||||
<RenamePopover
|
||||
node={node}
|
||||
open={renameOpen}
|
||||
onOpenChange={setRenameOpen}
|
||||
defaultName={defaultName}
|
||||
>
|
||||
<TreeNodeWrapper
|
||||
icon={<Layers className="w-3.5 h-3.5" />}
|
||||
label={node.name || `Level ${node.level}`}
|
||||
label={node.name || defaultName}
|
||||
depth={depth}
|
||||
hasChildren={node.children.length > 0}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
@@ -37,5 +52,6 @@ export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) {
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||
))}
|
||||
</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 { 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 { TreeNodeActions } from "./tree-node-actions";
|
||||
|
||||
@@ -10,6 +12,7 @@ interface RoofTreeNodeProps {
|
||||
}
|
||||
|
||||
export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
@@ -19,6 +22,10 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
|
||||
setSelection({ selectedIds: [node.id] });
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setRenameOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id);
|
||||
};
|
||||
@@ -30,21 +37,30 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
|
||||
// Calculate dimensions: length × total width (leftWidth + rightWidth)
|
||||
const totalWidth = node.leftWidth + node.rightWidth;
|
||||
const sizeLabel = `${node.length.toFixed(1)}×${totalWidth.toFixed(1)}m`;
|
||||
const defaultName = `Roof (${sizeLabel})`;
|
||||
|
||||
return (
|
||||
<RenamePopover
|
||||
node={node}
|
||||
open={renameOpen}
|
||||
onOpenChange={setRenameOpen}
|
||||
defaultName={defaultName}
|
||||
>
|
||||
<TreeNodeWrapper
|
||||
icon={<Home className="w-3.5 h-3.5" />}
|
||||
label={node.name || `Roof (${sizeLabel})`}
|
||||
icon={<Image src="/icons/roof.png" alt="" width={14} height={14} className="object-contain" />}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { SlabNode } from "@pascal-app/core";
|
||||
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 { TreeNodeActions } from "./tree-node-actions";
|
||||
|
||||
@@ -10,6 +12,7 @@ interface SlabTreeNodeProps {
|
||||
}
|
||||
|
||||
export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
@@ -19,6 +22,10 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
|
||||
setSelection({ selectedIds: [node.id] });
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setRenameOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id);
|
||||
};
|
||||
@@ -29,22 +36,31 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
|
||||
|
||||
// Calculate approximate area from polygon
|
||||
const area = calculatePolygonArea(node.polygon).toFixed(1);
|
||||
const defaultName = `Slab (${area}m²)`;
|
||||
|
||||
return (
|
||||
<RenamePopover
|
||||
node={node}
|
||||
open={renameOpen}
|
||||
onOpenChange={setRenameOpen}
|
||||
defaultName={defaultName}
|
||||
>
|
||||
<TreeNodeWrapper
|
||||
icon={<RectangleHorizontal className="w-3.5 h-3.5" />}
|
||||
label={node.name || `Slab (${area}m²)`}
|
||||
icon={<Image src="/icons/floor.png" alt="" width={14} height={14} className="object-contain" />}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AnyNodeId, useScene } from "@pascal-app/core";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BuildingTreeNode } from "./building-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 { SlabTreeNode } from "./slab-tree-node";
|
||||
import { WallTreeNode } from "./wall-tree-node";
|
||||
import { ZoneTreeNode } from "./zone-tree-node";
|
||||
|
||||
interface TreeNodeProps {
|
||||
nodeId: AnyNodeId;
|
||||
@@ -34,6 +36,8 @@ export function TreeNode({ nodeId, depth = 0 }: TreeNodeProps) {
|
||||
return <RoofTreeNode node={node} depth={depth} />;
|
||||
case "item":
|
||||
return <ItemTreeNode node={node} depth={depth} />;
|
||||
case "zone":
|
||||
return <ZoneTreeNode node={node} depth={depth} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -47,6 +51,7 @@ interface TreeNodeWrapperProps {
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onClick: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
actions?: React.ReactNode;
|
||||
@@ -55,7 +60,9 @@ interface TreeNodeWrapperProps {
|
||||
isHovered?: boolean;
|
||||
}
|
||||
|
||||
export function TreeNodeWrapper({
|
||||
export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
||||
function TreeNodeWrapper(
|
||||
{
|
||||
icon,
|
||||
label,
|
||||
depth,
|
||||
@@ -63,18 +70,21 @@ export function TreeNodeWrapper({
|
||||
expanded,
|
||||
onToggle,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
actions,
|
||||
children,
|
||||
isSelected,
|
||||
isHovered,
|
||||
}: TreeNodeWrapperProps) {
|
||||
},
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div>
|
||||
<div ref={ref}>
|
||||
<div
|
||||
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
|
||||
? "text-primary-foreground bg-primary/80 hover:bg-primary/90"
|
||||
: isHovered
|
||||
@@ -103,6 +113,7 @@ export function TreeNodeWrapper({
|
||||
<div
|
||||
className="flex items-center gap-1.5 flex-1 min-w-0"
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<span className="w-4 h-4 flex items-center justify-center shrink-0">
|
||||
{icon}
|
||||
@@ -119,3 +130,4 @@ export function TreeNodeWrapper({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { WallNode } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { Square } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { RenamePopover } from "./rename-popover";
|
||||
import { TreeNode, TreeNodeWrapper } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
|
||||
@@ -12,6 +13,7 @@ interface WallTreeNodeProps {
|
||||
|
||||
export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
@@ -21,6 +23,10 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
|
||||
setSelection({ selectedIds: [node.id] });
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setRenameOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id);
|
||||
};
|
||||
@@ -34,15 +40,24 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
|
||||
Math.pow(node.end[1] - node.start[1], 2),
|
||||
).toFixed(1);
|
||||
|
||||
const defaultName = `Wall (${wallLength}m/${node.height || 2.5}m)`;
|
||||
|
||||
return (
|
||||
<RenamePopover
|
||||
node={node}
|
||||
open={renameOpen}
|
||||
onOpenChange={setRenameOpen}
|
||||
defaultName={defaultName}
|
||||
>
|
||||
<TreeNodeWrapper
|
||||
icon={<Square className="w-3.5 h-3.5" />}
|
||||
label={node.name || `Wall (${wallLength}m/${node.height || 2.5}m)`}
|
||||
icon={<Image src="/icons/wall.png" alt="" width={14} height={14} className="object-contain" />}
|
||||
label={node.name || defaultName}
|
||||
depth={depth}
|
||||
hasChildren={node.children.length > 0}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
isSelected={isSelected}
|
||||
@@ -53,5 +68,6 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||
))}
|
||||
</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
|
||||
handleNodeCreated(node: AnyNode, levelId: string) {
|
||||
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)
|
||||
} else if (node.type === 'ceiling') {
|
||||
this.ceilings.set(node.id, node as CeilingNode)
|
||||
@@ -459,7 +458,6 @@ export class SpatialGridManager {
|
||||
end: [number, number],
|
||||
): number {
|
||||
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
|
||||
|
||||
let maxElevation = 0
|
||||
|
||||
@@ -107,7 +107,6 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
|
||||
|
||||
const levelId = resolveLevelId(node, nodes)
|
||||
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 childrenNodes = childrenIds
|
||||
|
||||
Reference in New Issue
Block a user