This commit is contained in:
wass08
2026-01-29 12:52:35 +09:00
parent 915eedcbce
commit f1a61350c7
19 changed files with 719 additions and 6 deletions
+2
View File
@@ -14,6 +14,7 @@ import { ZoneSystem } from '../systems/zone/zone-system'
import { ToolManager } from '../tools/tool-manager'
import { ActionMenu } from '../ui/action-menu'
import { ReferencePanel } from '../ui/panels/reference-panel'
import { RoofPanel } from '../ui/panels/roof-panel'
import { SidebarProvider } from '../ui/primitives/sidebar'
import { AppSidebar } from '../ui/sidebar/app-sidebar'
import { CustomCameraControls } from './custom-camera-controls'
@@ -33,6 +34,7 @@ export default function Editor() {
<TestUndo />
<ActionMenu />
<ReferencePanel />
<RoofPanel />
<SidebarProvider className="fixed z-10">
<AppSidebar />
@@ -20,7 +20,7 @@ const isNodeInCurrentLevel = (node: AnyNode): boolean => {
return nodeLevelId === currentLevelId;
};
type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling';
type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling' | 'roof';
interface SelectionStrategy {
types: SelectableNodeType[];
@@ -44,7 +44,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
},
structure: {
types: ["wall", "item", "zone", "slab", "ceiling"],
types: ["wall", "item", "zone", "slab", "ceiling", "roof"],
handleSelect: (node, isShift) => {
// Single click on item (door/window) → enter move mode
if (!isShift && node.type === 'item') {
@@ -78,7 +78,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
if (node.type === "zone") return true;
return false;
} else {
if (node.type === "wall" || node.type === "slab" || node.type === "ceiling") return true;
if (node.type === "wall" || node.type === "slab" || node.type === "ceiling" || node.type === "roof") return true;
if (node.type === "item") {
return (
(node as ItemNode).asset.category === "door" ||
@@ -0,0 +1,223 @@
import { emitter, type GridEvent, useScene, RoofNode, type LevelNode, type AnyNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { useEffect, useMemo, useRef, useState } from "react";
import { BufferGeometry, DoubleSide, type Line, Vector3 } from "three";
import useEditor from "@/store/use-editor";
// Default roof dimensions
const DEFAULT_HEIGHT = 1.5;
const PREVIEW_LINE_HEIGHT = 0.03; // Very thin preview
/**
* Creates a roof with the given corners
*/
const commitRoofPlacement = (
levelId: LevelNode["id"],
corner1: [number, number, number],
corner2: [number, number, number]
): RoofNode["id"] => {
const { createNode, nodes } = useScene.getState();
// Calculate center position and dimensions from corners
const centerX = (corner1[0] + corner2[0]) / 2;
const centerZ = (corner1[2] + corner2[2]) / 2;
const length = Math.abs(corner2[0] - corner1[0]);
const width = Math.abs(corner2[2] - corner1[2]);
// Split width evenly between left and right slopes
const slopeWidth = Math.max(width / 2, 0.5);
// Count existing roofs for naming
const roofCount = Object.values(nodes).filter((n) => n.type === "roof").length;
const name = `Roof ${roofCount + 1}`;
const roof = RoofNode.parse({
name,
position: [centerX, 0, centerZ], // Y is always 0
length: Math.max(length, 0.5),
height: DEFAULT_HEIGHT,
leftWidth: slopeWidth,
rightWidth: slopeWidth,
});
createNode(roof, levelId);
return roof.id;
};
type PreviewState = {
corner1: [number, number, number] | null;
cursorPosition: [number, number, number];
levelY: number;
};
export const RoofTool: React.FC = () => {
const outlineRef = useRef<Line>(null!);
const currentLevelId = useViewer((state) => state.selection.levelId);
const setSelection = useViewer((state) => state.setSelection);
const setTool = useEditor((state) => state.setTool);
const setMode = useEditor((state) => state.setMode);
const corner1Ref = useRef<[number, number, number] | null>(null);
const [preview, setPreview] = useState<PreviewState>({
corner1: null,
cursorPosition: [0, 0, 0],
levelY: 0,
});
useEffect(() => {
if (!currentLevelId) return;
// Initialize outline geometry
outlineRef.current.geometry = new BufferGeometry();
const updateOutline = (corner1: [number, number, number], corner2: [number, number, number]) => {
const y = corner1[1] + PREVIEW_LINE_HEIGHT;
const points = [
new Vector3(corner1[0], y, corner1[2]),
new Vector3(corner2[0], y, corner1[2]),
new Vector3(corner2[0], y, corner2[2]),
new Vector3(corner1[0], y, corner2[2]),
new Vector3(corner1[0], y, corner1[2]), // Close the loop
];
outlineRef.current.geometry.dispose();
outlineRef.current.geometry = new BufferGeometry().setFromPoints(points);
outlineRef.current.visible = true;
};
const onGridMove = (event: GridEvent) => {
// Snap to 0.5 grid
const gridX = Math.round(event.position[0] * 2) / 2;
const gridZ = Math.round(event.position[2] * 2) / 2;
const y = event.position[1];
const cursorPosition: [number, number, number] = [gridX, y, gridZ];
setPreview({
corner1: corner1Ref.current,
cursorPosition,
levelY: y,
});
// Update outline if we have first corner
if (corner1Ref.current) {
updateOutline(corner1Ref.current, cursorPosition);
}
};
const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return;
const gridX = Math.round(event.position[0] * 2) / 2;
const gridZ = Math.round(event.position[2] * 2) / 2;
const y = event.position[1];
if (!corner1Ref.current) {
// First click - set corner 1
corner1Ref.current = [gridX, y, gridZ];
setPreview((prev) => ({
...prev,
corner1: corner1Ref.current,
}));
} else {
// Second click - create the roof
const roofId = commitRoofPlacement(
currentLevelId,
corner1Ref.current,
[gridX, y, gridZ]
);
// Auto-select the newly created roof
setSelection({ selectedIds: [roofId as AnyNode["id"]] });
// Reset state
corner1Ref.current = null;
outlineRef.current.visible = false;
// Switch to select mode and deactivate tool
setMode('select');
setTool(null);
}
};
// Subscribe to events
emitter.on("grid:move", onGridMove);
emitter.on("grid:click", onGridClick);
return () => {
emitter.off("grid:move", onGridMove);
emitter.off("grid:click", onGridClick);
// Reset state on unmount
corner1Ref.current = null;
};
}, [currentLevelId, setTool, setSelection, setMode]);
const { corner1, cursorPosition, levelY } = preview;
// Calculate preview dimensions for display
const previewDimensions = useMemo(() => {
if (!corner1) return null;
const length = Math.abs(cursorPosition[0] - corner1[0]);
const width = Math.abs(cursorPosition[2] - corner1[2]);
const centerX = (corner1[0] + cursorPosition[0]) / 2;
const centerZ = (corner1[2] + cursorPosition[2]) / 2;
return { length, width, centerX, centerZ };
}, [corner1, cursorPosition]);
return (
<group>
{/* Outline showing rectangle being drawn */}
{/* @ts-ignore */}
<line ref={outlineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry />
<lineBasicNodeMaterial
color="#8b4513"
linewidth={2}
depthTest={false}
depthWrite={false}
/>
</line>
{/* First corner marker */}
{corner1 && (
<mesh position={[corner1[0], levelY + 0.02, corner1[2]]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.1, 0.15, 32]} />
<meshBasicMaterial
color="#22c55e"
depthTest={false}
depthWrite={false}
/>
</mesh>
)}
{/* Cursor marker on ground */}
<mesh position={[cursorPosition[0], cursorPosition[1] + 0.02, cursorPosition[2]]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.1, 0.15, 32]} />
<meshBasicMaterial
color="#8b4513"
depthTest={false}
depthWrite={false}
/>
</mesh>
{/* Thin preview fill when drawing */}
{previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && (
<mesh
position={[previewDimensions.centerX, levelY + 0.01, previewDimensions.centerZ]}
rotation={[-Math.PI / 2, 0, 0]}
>
<planeGeometry args={[previewDimensions.length, previewDimensions.width]} />
<meshBasicMaterial
color="#8b4513"
opacity={0.2}
transparent
side={DoubleSide}
depthTest={false}
depthWrite={false}
/>
</mesh>
)}
</group>
);
};
@@ -3,6 +3,7 @@ import { useViewer } from "@pascal-app/viewer";
import { CeilingTool } from "./ceiling/ceiling-tool";
import { ItemTool } from "./item/item-tool";
import { MoveTool } from "./item/move-tool";
import { RoofTool } from "./roof/roof-tool";
import { SlabTool } from "./slab/slab-tool";
import { WallTool } from "./wall/wall-tool";
import { ZoneBoundaryEditor } from "./zone/zone-boundary-editor";
@@ -14,6 +15,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
wall: WallTool,
slab: SlabTool,
ceiling: CeilingTool,
roof: RoofTool,
item: ItemTool,
zone: ZoneTool,
},
@@ -0,0 +1,232 @@
'use client'
import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Home, X } from 'lucide-react'
import { useCallback } from 'react'
export function RoofPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
// Get the first selected node if it's a roof
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<RoofNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
// Only show if exactly one roof is selected
if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
// Calculate total width for display
const totalWidth = node.leftWidth + node.rightWidth
return (
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-72 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md">
{/* 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" />
<h2 className="font-semibold text-foreground text-sm truncate">
{node.name || 'Gable Roof'}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
onClick={handleClose}
>
<X className="h-4 w-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-4">
{/* Length */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Length
</label>
<div className="flex items-center gap-2">
<input
className="flex-1 rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
min="0.5"
onChange={(e) => {
const value = Number.parseFloat(e.target.value)
if (!Number.isNaN(value) && value > 0) {
handleUpdate({ length: value })
}
}}
step="0.5"
type="number"
value={Math.round(node.length * 100) / 100}
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
{/* Height */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Height
</label>
<div className="flex items-center gap-2">
<input
className="flex-1 rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
min="0.1"
onChange={(e) => {
const value = Number.parseFloat(e.target.value)
if (!Number.isNaN(value) && value > 0) {
handleUpdate({ height: value })
}
}}
step="0.1"
type="number"
value={Math.round(node.height * 100) / 100}
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
{/* Slope Widths */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Slope Widths
</label>
<span className="text-muted-foreground text-xs">
Total: {totalWidth.toFixed(1)}m
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-muted-foreground text-xs">Left</label>
<div className="flex items-center gap-1">
<input
className="w-full rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
min="0.1"
onChange={(e) => {
const value = Number.parseFloat(e.target.value)
if (!Number.isNaN(value) && value > 0) {
handleUpdate({ leftWidth: value })
}
}}
step="0.1"
type="number"
value={Math.round(node.leftWidth * 100) / 100}
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
<div className="space-y-1">
<label className="text-muted-foreground text-xs">Right</label>
<div className="flex items-center gap-1">
<input
className="w-full rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
min="0.1"
onChange={(e) => {
const value = Number.parseFloat(e.target.value)
if (!Number.isNaN(value) && value > 0) {
handleUpdate({ rightWidth: value })
}
}}
step="0.1"
type="number"
value={Math.round(node.rightWidth * 100) / 100}
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
</div>
</div>
{/* Rotation */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Rotation
</label>
<div className="flex items-center gap-1.5">
<input
className="min-w-0 flex-1 rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
onChange={(e) => {
const degrees = Number.parseFloat(e.target.value)
if (!Number.isNaN(degrees)) {
const radians = (degrees * Math.PI) / 180
handleUpdate({ rotation: radians })
}
}}
step="1"
type="number"
value={Math.round((node.rotation * 180) / Math.PI)}
/>
<span className="text-muted-foreground text-xs shrink-0">&deg;</span>
<button
type="button"
className="shrink-0 rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent cursor-pointer"
onClick={() => {
const newRotation = node.rotation - Math.PI / 2
handleUpdate({ rotation: newRotation })
}}
>
&minus;90
</button>
<button
type="button"
className="shrink-0 rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent cursor-pointer"
onClick={() => {
const newRotation = node.rotation + Math.PI / 2
handleUpdate({ rotation: newRotation })
}}
>
+90
</button>
</div>
</div>
{/* Position */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Position
</label>
<div className="grid grid-cols-3 gap-2">
{([0, 1, 2] as const).map((i) => (
<div key={i} className="space-y-1">
<label className="text-muted-foreground text-xs">{['X', 'Y', 'Z'][i]}</label>
<input
className="w-full rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
onChange={(e) => {
const value = Number.parseFloat(e.target.value)
if (!Number.isNaN(value)) {
const pos = [...node.position] as [number, number, number]
pos[i] = value
handleUpdate({ position: pos })
}
}}
step="0.5"
type="number"
value={Math.round(node.position[i] * 100) / 100}
/>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,50 @@
import { RoofNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { Home } from "lucide-react";
import { TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions";
interface RoofTreeNodeProps {
node: RoofNode;
depth: number;
}
export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
const isSelected = useViewer((state) => state.selection.selectedIds.includes(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({ selectedIds: [node.id] });
};
const handleMouseEnter = () => {
setHoveredId(node.id);
};
const handleMouseLeave = () => {
setHoveredId(null);
};
// Calculate dimensions: length × total width (leftWidth + rightWidth)
const totalWidth = node.leftWidth + node.rightWidth;
const sizeLabel = `${node.length.toFixed(1)}×${totalWidth.toFixed(1)}m`;
return (
<TreeNodeWrapper
icon={<Home className="w-3.5 h-3.5" />}
label={node.name || `Roof (${sizeLabel})`}
depth={depth}
hasChildren={false}
expanded={false}
onToggle={() => {}}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
/>
);
}
@@ -5,6 +5,7 @@ import { BuildingTreeNode } from "./building-tree-node";
import { CeilingTreeNode } from "./ceiling-tree-node";
import { ItemTreeNode } from "./item-tree-node";
import { LevelTreeNode } from "./level-tree-node";
import { RoofTreeNode } from "./roof-tree-node";
import { SlabTreeNode } from "./slab-tree-node";
import { WallTreeNode } from "./wall-tree-node";
@@ -29,6 +30,8 @@ export function TreeNode({ nodeId, depth = 0 }: TreeNodeProps) {
return <SlabTreeNode node={node} depth={depth} />;
case "wall":
return <WallTreeNode node={node} depth={depth} />;
case "roof":
return <RoofTreeNode node={node} depth={depth} />;
case "item":
return <ItemTreeNode node={node} depth={depth} />;
default: