diff --git a/apps/editor/app/globals.css b/apps/editor/app/globals.css index 9b1fb8f0..1ff450ab 100644 --- a/apps/editor/app/globals.css +++ b/apps/editor/app/globals.css @@ -3,12 +3,18 @@ @custom-variant dark (&:is(.dark *)); +@theme { + --font-sans: var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + --font-barlow: var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; +} + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); - --color-sidebar-ring: var(--sidebar-ring); + --font-sans: var(--font-barlow), sans-serif; + --font-mono: var(--font-geist-mono), monospace; + --font-barlow: var(--font-barlow), sans-serif; --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent: var(--sidebar-accent); diff --git a/apps/editor/app/layout.tsx b/apps/editor/app/layout.tsx index b378f05b..234cc565 100644 --- a/apps/editor/app/layout.tsx +++ b/apps/editor/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next' import localFont from 'next/font/local' +import { Barlow } from 'next/font/google' import { Analytics } from '@vercel/analytics/react' import { SpeedInsights } from '@vercel/speed-insights/next' import { VercelToolbar } from '@vercel/toolbar/next' @@ -16,6 +17,13 @@ const geistMono = localFont({ variable: '--font-geist-mono', }) +const barlow = Barlow({ + subsets: ['latin'], + weight: ['400', '500', '600', '700'], + variable: '--font-barlow', + display: 'swap', +}) + export const metadata: Metadata = { metadataBase: new URL(siteConfig.url), title: { @@ -69,8 +77,8 @@ export default function RootLayout({ const shouldShowToolbar = process.env.NODE_ENV === 'development' return ( - - + + {children} diff --git a/apps/editor/components/editor/selection-manager.tsx b/apps/editor/components/editor/selection-manager.tsx index 239757d8..57c12cc6 100644 --- a/apps/editor/components/editor/selection-manager.tsx +++ b/apps/editor/components/editor/selection-manager.tsx @@ -123,32 +123,16 @@ export const SelectionManager = () => { const strategy = SELECTION_STRATEGIES[phase]; if (!strategy) return; - const onEnter = (event: NodeEvent) => { - if (strategy.isValid(event.node)) { - event.stopPropagation(); - useViewer.setState({ hoveredId: event.node.id }); - } - }; - - const onLeave = (event: NodeEvent) => { - if (strategy.isValid(event.node)) { - event.stopPropagation(); - useViewer.setState({ hoveredId: null }); - } - }; - const onClick = (event: NodeEvent) => { if (!strategy.isValid(event.node)) return; event.stopPropagation(); const isShift = event.nativeEvent?.shiftKey; - strategy.handleSelect(event.node, isShift); + strategy.handleSelect(event.node, isShift ?? false); }; // Bind listeners for all potential types this strategy might care about strategy.types.forEach((type) => { - emitter.on(`${type}:enter`, onEnter); - emitter.on(`${type}:leave`, onLeave); emitter.on(`${type}:click`, onClick); }); @@ -157,14 +141,118 @@ export const SelectionManager = () => { return () => { strategy.types.forEach((type) => { - emitter.off(`${type}:enter`, onEnter); - emitter.off(`${type}:leave`, onLeave); emitter.off(`${type}:click`, onClick); }); emitter.off("grid:click", onGridClick); }; }, [phase, mode, movingNode]); + // Global double-click handler for auto-switching phases and cross-phase hover + useEffect(() => { + if (mode !== "select") return; + if (movingNode) return; + + const onEnter = (event: NodeEvent) => { + const node = event.node; + const currentPhase = useEditor.getState().phase; + + // Ignore site/building if we are already inside a building + if (node.type === "building" || node.type === "site") { + if (currentPhase === "structure" || currentPhase === "furnish") { + return; + } + } + + // Ignore zones unless specifically in zones layer + if (node.type === "zone") { + if (currentPhase !== "structure" || useEditor.getState().structureLayer !== "zones") { + return; + } + } + + // Check level constraint for interior nodes + if (currentPhase === "structure" || currentPhase === "furnish") { + if (!isNodeInCurrentLevel(node)) return; + } + + event.stopPropagation(); + useViewer.setState({ hoveredId: node.id }); + }; + + const onLeave = (event: NodeEvent) => { + if (useViewer.getState().hoveredId === event.node.id) { + useViewer.setState({ hoveredId: null }); + } + }; + + const onDoubleClick = (event: NodeEvent) => { + const node = event.node; + const currentPhase = useEditor.getState().phase; + + let targetPhase: "site" | "structure" | "furnish" | null = null; + + if (node.type === "building" || node.type === "site") { + if (currentPhase === "structure" || currentPhase === "furnish") { + return; // Ignore building/site double clicks if we are already inside a building + } + if (node.type === "building") { + targetPhase = "structure"; + } + } else if ( + node.type === "wall" || + node.type === "slab" || + node.type === "ceiling" || + node.type === "roof" || + node.type === "window" || + node.type === "door" + ) { + targetPhase = "structure"; + } else if (node.type === "item") { + const item = node as ItemNode; + if (item.asset.category === "door" || item.asset.category === "window") { + targetPhase = "structure"; + } else { + targetPhase = "furnish"; + } + } + + if (node.type === "zone") { + return; + } + + if (targetPhase && targetPhase !== useEditor.getState().phase) { + event.stopPropagation(); + + useEditor.getState().setPhase(targetPhase); + + if (targetPhase === "structure" && useEditor.getState().structureLayer === "zones") { + useEditor.getState().setStructureLayer("elements"); + } + + const strategy = SELECTION_STRATEGIES[targetPhase]; + if (strategy) { + const isShift = event.nativeEvent?.shiftKey; + strategy.handleSelect(node, isShift ?? false); + } + } + }; + + const allTypes = ["wall", "item", "building", "slab", "ceiling", "roof", "window", "door", "zone", "site"]; + allTypes.forEach((type) => { + emitter.on(`${type}:enter` as any, onEnter as any); + emitter.on(`${type}:leave` as any, onLeave as any); + emitter.on(`${type}:double-click` as any, onDoubleClick as any); + }); + + return () => { + allTypes.forEach((type) => { + emitter.off(`${type}:enter` as any, onEnter as any); + emitter.off(`${type}:leave` as any, onLeave as any); + emitter.off(`${type}:double-click` as any, onDoubleClick as any); + }); + }; + }, [mode, movingNode]); + return ; }; diff --git a/apps/editor/components/tools/ceiling/ceiling-tool.tsx b/apps/editor/components/tools/ceiling/ceiling-tool.tsx index 0af3f984..244743c0 100644 --- a/apps/editor/components/tools/ceiling/ceiling-tool.tsx +++ b/apps/editor/components/tools/ceiling/ceiling-tool.tsx @@ -1,7 +1,7 @@ import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' -import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three' +import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } from 'three' import { mix, positionLocal } from 'three/tsl' import { sfxEmitter } from '@/lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' @@ -66,10 +66,12 @@ const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array<[number, n } export const CeilingTool: React.FC = () => { - const cursorRef = useRef(null) - const gridCursorRef = useRef(null) + const cursorRef = useRef(null) + const gridCursorRef = useRef(null) const mainLineRef = useRef(null!) const closingLineRef = useRef(null!) + const groundMainLineRef = useRef(null!) + const groundClosingLineRef = useRef(null!) const verticalLineRef = useRef(null!) const currentLevelId = useViewer((state) => state.selection.levelId) const setSelection = useViewer((state) => state.setSelection) @@ -217,13 +219,22 @@ export const CeilingTool: React.FC = () => { const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z)) linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1])) + const gridY = levelY + GRID_OFFSET + const groundLinePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, gridY, z)) + groundLinePoints.push(new Vector3(snappedCursor[0], gridY, snappedCursor[1])) + // Update main line if (linePoints.length >= 2) { mainLineRef.current.geometry.dispose() mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints) mainLineRef.current.visible = true + + groundMainLineRef.current.geometry.dispose() + groundMainLineRef.current.geometry = new BufferGeometry().setFromPoints(groundLinePoints) + groundMainLineRef.current.visible = true } else { mainLineRef.current.visible = false + groundMainLineRef.current.visible = false } // Update closing line (from cursor back to first point) @@ -236,8 +247,17 @@ export const CeilingTool: React.FC = () => { closingLineRef.current.geometry.dispose() closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints) closingLineRef.current.visible = true + + const groundClosingPoints = [ + new Vector3(snappedCursor[0], gridY, snappedCursor[1]), + new Vector3(firstPoint[0], gridY, firstPoint[1]), + ] + groundClosingLineRef.current.geometry.dispose() + groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints(groundClosingPoints) + groundClosingLineRef.current.visible = true } else { closingLineRef.current.visible = false + groundClosingLineRef.current.visible = false } }, [points, snappedCursorPosition, levelY]) @@ -277,16 +297,16 @@ export const CeilingTool: React.FC = () => { {/* Grid-level cursor indicator */} - + {/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */} {/* @ts-ignore */} - + - {/* Preview fill */} + {/* Preview fill (Top) */} {previewShape && ( { > + + )} + + {/* Preview fill (Ground) */} + {previewShape && ( + + + @@ -308,7 +346,7 @@ export const CeilingTool: React.FC = () => { {/* @ts-ignore */} - + {/* Closing line */} @@ -316,7 +354,7 @@ export const CeilingTool: React.FC = () => { { /> + {/* Ground main line */} + {/* @ts-ignore */} + + + + + + {/* Ground closing line */} + {/* @ts-ignore */} + + + + + {/* Point markers */} {points.map(([x, z], index) => ( ))} diff --git a/apps/editor/components/tools/roof/roof-tool.tsx b/apps/editor/components/tools/roof/roof-tool.tsx index 6698ae75..777303e3 100644 --- a/apps/editor/components/tools/roof/roof-tool.tsx +++ b/apps/editor/components/tools/roof/roof-tool.tsx @@ -8,13 +8,15 @@ import { } 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 { BufferGeometry, DoubleSide, type Line, type Group, Vector3 } from 'three' import { sfxEmitter } from '@/lib/sfx-bus' import useEditor from '@/store/use-editor' +import { CursorSphere } from '../shared/cursor-sphere' // Default roof dimensions const DEFAULT_HEIGHT = 1.5 -const PREVIEW_LINE_HEIGHT = 0.03 // Very thin preview +const CEILING_HEIGHT = 2.52 +const GRID_OFFSET = 0.02 /** * Creates a roof with the given corners @@ -61,6 +63,7 @@ type PreviewState = { } export const RoofTool: React.FC = () => { + const cursorRef = useRef(null) const outlineRef = useRef(null!) const currentLevelId = useViewer((state) => state.selection.levelId) const setSelection = useViewer((state) => state.setSelection) @@ -85,20 +88,24 @@ export const RoofTool: React.FC = () => { 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 + const gridY = corner1[1] + GRID_OFFSET + + const groundPoints = [ + new Vector3(corner1[0], gridY, corner1[2]), + new Vector3(corner2[0], gridY, corner1[2]), + new Vector3(corner2[0], gridY, corner2[2]), + new Vector3(corner1[0], gridY, corner2[2]), + new Vector3(corner1[0], gridY, corner1[2]), // Close the loop ] + outlineRef.current.geometry.dispose() - outlineRef.current.geometry = new BufferGeometry().setFromPoints(points) + outlineRef.current.geometry = new BufferGeometry().setFromPoints(groundPoints) outlineRef.current.visible = true } const onGridMove = (event: GridEvent) => { + if (!cursorRef.current) return + // Snap to 0.5 grid const gridX = Math.round(event.position[0] * 2) / 2 const gridZ = Math.round(event.position[2] * 2) / 2 @@ -106,6 +113,11 @@ export const RoofTool: React.FC = () => { const cursorPosition: [number, number, number] = [gridX, y, gridZ] + // Update cursors + const gridY = y + GRID_OFFSET + + cursorRef.current.position.set(gridX, gridY, gridZ) + // Play snap sound when grid position changes (only when placing) if ( corner1Ref.current && @@ -153,10 +165,6 @@ export const RoofTool: React.FC = () => { // Reset state corner1Ref.current = null outlineRef.current.visible = false - - // Switch to select mode and deactivate tool - setMode('select') - setTool(null) } } @@ -197,41 +205,35 @@ export const RoofTool: React.FC = () => { return ( - {/* Outline showing rectangle being drawn */} + {/* Cursor at ground height */} + + + {/* Outline showing rectangle being drawn (Ground) */} {/* @ts-ignore */} - + {/* First corner marker */} {corner1 && ( - - - - + )} - {/* Cursor marker on ground */} - - - - - - {/* Thin preview fill when drawing */} + {/* Thin preview fill when drawing (Ground) */} {previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && ( { +interface CursorSphereProps extends Omit { color?: string depthWrite?: boolean + showTooltip?: boolean + height?: number } -export const CursorSphere = forwardRef(function CursorSphere( - { color = '#f1c066', ...props }, +export const CursorSphere = forwardRef(function CursorSphere( + { color = '#818cf8', showTooltip = true, height = 2.5, ...props }, ref, ) { + const tool = useEditor((s) => s.tool) + const mode = useEditor((s) => s.mode) + const catalogCategory = useEditor((s) => s.catalogCategory) + + // Find the icon for the current tool + let activeToolConfig = null + if (mode === 'build' && tool) { + if (tool === 'item' && catalogCategory) { + activeToolConfig = furnishTools.find((t) => t.catalogCategory === catalogCategory) + } else { + activeToolConfig = tools.find((t) => t.id === tool) + } + } + return ( - - - - + + {/* Flat marker on the ground */} + + {/* Center dot */} + + + + + + {/* Outer ring / glow */} + + + + + + + {/* Vertical line */} + {height > 0 && ( + + + + + )} + + {/* Tool Icon Tooltip at the top of the line */} + {showTooltip && activeToolConfig && ( + 0 ? height + 0.2 : 0.6, 0]} + center + style={{ + pointerEvents: 'none', + background: '#18181b', // zinc-900 + padding: '6px', + borderRadius: '12px', + border: '1px solid rgba(255,255,255,0.05)', + boxShadow: '0 8px 16px -4px rgba(0, 0, 0, 0.3), 0 4px 8px -4px rgba(0, 0, 0, 0.2)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '36px', + height: '36px', + }} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + {activeToolConfig.label} + + )} + ) }) diff --git a/apps/editor/components/tools/slab/slab-tool.tsx b/apps/editor/components/tools/slab/slab-tool.tsx index 7f88f54f..54630e25 100644 --- a/apps/editor/components/tools/slab/slab-tool.tsx +++ b/apps/editor/components/tools/slab/slab-tool.tsx @@ -1,7 +1,7 @@ import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' -import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three' +import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } from 'three' import { sfxEmitter } from '@/lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' @@ -64,7 +64,7 @@ const commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, numb } export const SlabTool: React.FC = () => { - const cursorRef = useRef(null) + const cursorRef = useRef(null) const mainLineRef = useRef(null!) const closingLineRef = useRef(null!) const currentLevelId = useViewer((state) => state.selection.levelId) @@ -248,9 +248,9 @@ export const SlabTool: React.FC = () => { > @@ -261,7 +261,7 @@ export const SlabTool: React.FC = () => { {/* @ts-ignore */} - + {/* Closing line */} @@ -269,7 +269,7 @@ export const SlabTool: React.FC = () => { { {/* Point markers */} {points.map(([x, z], index) => ( - + ))} ) diff --git a/apps/editor/components/tools/wall/wall-tool.tsx b/apps/editor/components/tools/wall/wall-tool.tsx index 72755cb0..abe04962 100644 --- a/apps/editor/components/tools/wall/wall-tool.tsx +++ b/apps/editor/components/tools/wall/wall-tool.tsx @@ -1,7 +1,7 @@ import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' -import { DoubleSide, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' +import { DoubleSide, type Mesh, type Group, Shape, ShapeGeometry, Vector3 } from 'three' import { sfxEmitter } from '@/lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' @@ -94,7 +94,7 @@ const commitWallDrawing = (start: [number, number], end: [number, number]) => { } export const WallTool: React.FC = () => { - const cursorRef = useRef(null) + const cursorRef = useRef(null) const wallPreviewRef = useRef(null!) const startingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0)) @@ -110,7 +110,6 @@ export const WallTool: React.FC = () => { gridPosition = [Math.round(event.position[0] * 2) / 2, Math.round(event.position[2] * 2) / 2] const cursorPosition = new Vector3(gridPosition[0], event.position[1], gridPosition[1]) - cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1]) if (buildingState.current === 1) { // Snap to 45° angles only if shift is not pressed @@ -119,6 +118,9 @@ export const WallTool: React.FC = () => { : snapTo45Degrees(startingPoint.current, cursorPosition) endingPoint.current.copy(snapped) + // Position the cursor at the end of the wall being drawn + cursorRef.current.position.set(snapped.x, snapped.y, snapped.z) + // Play snap sound only when the actual wall end position changes const currentWallEnd: [number, number] = [endingPoint.current.x, endingPoint.current.z] if (previousWallEnd && @@ -129,6 +131,9 @@ export const WallTool: React.FC = () => { // Update wall preview geometry updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current) + } else { + // Not drawing a wall, just follow the grid position + cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1]) } } @@ -190,7 +195,7 @@ export const WallTool: React.FC = () => { { - const cursorRef = useRef(null); + const cursorRef = useRef(null); const mainLineRef = useRef(null!); const closingLineRef = useRef(null!); const pointsRef = useRef>([]); @@ -241,9 +241,6 @@ export const ZoneTool: React.FC = () => { setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current }); mainLineRef.current.visible = false; closingLineRef.current.visible = false; - - // Deactivate tool - setTool(null); } else { // Add point to polygon pointsRef.current = [...pointsRef.current, clickPoint]; @@ -263,9 +260,6 @@ export const ZoneTool: React.FC = () => { setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current }); mainLineRef.current.visible = false; closingLineRef.current.visible = false; - - // Deactivate tool - setTool(null); } }; @@ -318,7 +312,7 @@ export const ZoneTool: React.FC = () => { return ( {/* Cursor */} - + {/* Preview fill */} {previewShape && ( @@ -329,7 +323,7 @@ export const ZoneTool: React.FC = () => { > { { { {/* Point markers */} {points.map(([x, z], index) => isValidPoint([x, z]) ? ( - + ) : null )} diff --git a/apps/editor/components/ui/action-menu/furnish-tools.tsx b/apps/editor/components/ui/action-menu/furnish-tools.tsx index 23486e8b..0fedf6f4 100644 --- a/apps/editor/components/ui/action-menu/furnish-tools.tsx +++ b/apps/editor/components/ui/action-menu/furnish-tools.tsx @@ -56,11 +56,18 @@ export function FurnishTools() { const mode = useEditor((state) => state.mode); const activeTool = useEditor((state) => state.tool); const setActiveTool = useEditor((state) => state.setTool); + const setMode = useEditor((state) => state.setMode); const catalogCategory = useEditor((state) => state.catalogCategory); const setCatalogCategory = useEditor((state) => state.setCatalogCategory); + const hasActiveTool = furnishTools.some((tool) => + mode === "build" && + activeTool === "item" && + catalogCategory === tool.catalogCategory + ); + return ( -
+
{furnishTools.map((tool, index) => { // For item tools with catalog category, check both tool and category match const isActive = @@ -73,13 +80,23 @@ export function FurnishTools() { - - -

- {mode.label} ({mode.shortcut}) -

-

{mode.description}

-
- - ); - })} -
- ); -} diff --git a/apps/editor/components/ui/action-menu/structure-tools.tsx b/apps/editor/components/ui/action-menu/structure-tools.tsx index edaf8f7a..f4101739 100644 --- a/apps/editor/components/ui/action-menu/structure-tools.tsx +++ b/apps/editor/components/ui/action-menu/structure-tools.tsx @@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/primit import { cn } from '@/lib/utils' import useEditor, { CatalogCategory, StructureTool, Tool } from '@/store/use-editor' +import { useContextualTools } from '@/hooks/use-contextual-tools' export type ToolConfig = { id: StructureTool; iconSrc: string; label: string; catalogCategory?: CatalogCategory } @@ -28,28 +29,39 @@ export function StructureTools() { const structureLayer = useEditor((state) => state.structureLayer) const setTool = useEditor((state) => state.setTool) const setCatalogCategory = useEditor((state) => state.setCatalogCategory) + + const contextualTools = useContextualTools() // Filter tools based on structureLayer const visibleTools = structureLayer === 'zones' ? tools.filter((t) => t.id === 'zone') : tools.filter((t) => t.id !== 'zone') + const hasActiveTool = visibleTools.some((t) => + activeTool === t.id && + (t.catalogCategory ? catalogCategory === t.catalogCategory : true) + ) + return ( -
+
{visibleTools.map((tool, index) => { // For item tools with catalog category, check both tool and category match const isActive = activeTool === tool.id && (tool.catalogCategory ? catalogCategory === tool.catalogCategory : true) + + const isContextual = contextualTools.includes(tool.id) return ( @@ -178,16 +178,16 @@ export function ViewToggles() { diff --git a/apps/editor/components/ui/panels/ceiling-panel.tsx b/apps/editor/components/ui/panels/ceiling-panel.tsx index 96b1db73..5ab6a8a2 100644 --- a/apps/editor/components/ui/panels/ceiling-panel.tsx +++ b/apps/editor/components/ui/panels/ceiling-panel.tsx @@ -119,16 +119,16 @@ export function CeilingPanel() { return (
{/* Header */} -
+
-

+

{node.name || `Ceiling (${area.toFixed(1)}m²)`}

Direction -
+
{(['inward', 'outward'] as const).map((dir) => (
{/* Action Buttons */} -
+
- setBuildingCameraOpen(open ? building.id : null)} - > - - - - e.stopPropagation()} - > -
- {building.camera && ( - - )} - - {building.camera && ( - - )} -
-
-
-
- ))} -
- )} -
- ); -} - -// ============================================================================ -// 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) => { - const id = typeof child === 'string' ? child : child.id; - return nodes[id] as BuildingNode | undefined; - }) - .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"} - + > +
setSelection({ levelId: level.id })} + onDoubleClick={() => setIsEditing(true)} + > + + setIsEditing(false)} + onStartEditing={() => setIsEditing(true)} + defaultName={`Level ${level.level}`} + />
- ); - } - - return ( - - - - - - {buildings.map((building) => ( - + + e.stopPropagation()} > - - {building.name || "Building"} - - ))} - - +
+ {level.camera && ( + + )} + + {level.camera && ( + + )} +
+ + + + + + + + + {level.level !== 0 && ( + + )} + + +
); } @@ -529,7 +474,6 @@ function LevelsSection() { const setSelection = useViewer((state) => state.setSelection); const [referencesLevelId, setReferencesLevelId] = useState(null); - const [cameraPopoverOpen, setCameraPopoverOpen] = useState(null); const building = selectedBuildingId ? (nodes[selectedBuildingId] as BuildingNode) @@ -552,9 +496,9 @@ function LevelsSection() { }; return ( -
+
{/* Header */} -
+
Levels @@ -567,123 +511,17 @@ function LevelsSection() {
{/* Level buttons */} -
+
{levels.map((level) => ( -
- - {/* Camera snapshot button */} - setCameraPopoverOpen(open ? level.id : null)}> - - - - e.stopPropagation()} - > -
- {level.camera && ( - - )} - - {level.camera && ( - - )} -
-
-
- - - - - - - {level.level !== 0 && ( - - )} - - -
+ level={level} + selectedLevelId={selectedLevelId} + setSelection={setSelection} + setReferencesLevelId={setReferencesLevelId} + deleteNode={deleteNode} + updateNode={updateNode} + /> ))} {levels.length === 0 && (
@@ -709,29 +547,65 @@ function LevelsSection() { function LayerToggle() { const structureLayer = useEditor((state) => state.structureLayer); const setStructureLayer = useEditor((state) => state.setStructureLayer); + const phase = useEditor((state) => state.phase); + const setPhase = useEditor((state) => state.setPhase); return ( -
+
+
@@ -739,7 +613,7 @@ function LayerToggle() { } function ZoneItem({ zone }: { zone: ZoneNode }) { - const [renameOpen, setRenameOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false); const deleteNode = useScene((state) => state.deleteNode); const updateNode = useScene((state) => state.updateNode); @@ -753,6 +627,14 @@ function ZoneItem({ zone }: { zone: ZoneNode }) { const isSelected = selectedZoneId === zone.id; const isHovered = hoveredId === zone.id; + const itemRef = useRef(null); + + useEffect(() => { + if (isSelected && itemRef.current) { + itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + }, [isSelected]); + const area = calculatePolygonArea(zone.polygon).toFixed(1); const defaultName = `Zone (${area}m²)`; @@ -763,7 +645,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) { }; const handleDoubleClick = () => { - setRenameOpen(true); + setIsEditing(true); }; const handleDelete = (e: React.MouseEvent) => { @@ -779,124 +661,127 @@ function ZoneItem({ zone }: { zone: ZoneNode }) { }; return ( - setHoveredId(zone.id)} + onMouseLeave={() => setHoveredId(null)} > -
setHoveredId(zone.id)} - onMouseLeave={() => setHoveredId(null)} - > - - -
- - - {zone.name || defaultName} - {/* Camera snapshot button */} - - - - - + + e.stopPropagation()} + > +
+ {PRESET_COLORS.map((color) => ( +
+
+
+ setIsEditing(false)} + onStartEditing={() => setIsEditing(true)} + defaultName={defaultName} + /> + {/* Camera snapshot button */} + + + - )} + + {zone.camera && ( + + )} + + + e.stopPropagation()} + > +
+ {zone.camera && ( - {zone.camera && ( - - )} -
-
-
- -
- + )} + + {zone.camera && ( + + )} +
+ + + +
); } @@ -947,7 +832,7 @@ function ContentSection() { } return ( -
+
{levelZones.map((zone) => ( ))} @@ -994,7 +879,7 @@ function ContentSection() { } return ( -
+
{elementChildren.map((childId) => ( ))} @@ -1002,32 +887,214 @@ function ContentSection() { ); } -function StructurePhaseView() { +function BuildingItem({ + building, + isBuildingActive, + buildingCameraOpen, + setBuildingCameraOpen, +}: { + building: BuildingNode; + isBuildingActive: boolean; + buildingCameraOpen: string | null; + setBuildingCameraOpen: (id: string | null) => void; +}) { + const setSelection = useViewer((state) => state.setSelection); const phase = useEditor((state) => state.phase); + const setPhase = useEditor((state) => state.setPhase); + const updateNode = useScene((state) => state.updateNode); + const itemRef = useRef(null); + + useEffect(() => { + if (isBuildingActive && itemRef.current) { + itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + }, [isBuildingActive]); return ( -
- - - {/* Only show layer toggle in structure phase, furnish is always elements */} - {phase === "structure" && } -
- +
+
+ + setBuildingCameraOpen(open ? building.id : null)} + > + + + + e.stopPropagation()} + > +
+ {building.camera && ( + + )} + + {building.camera && ( + + )} +
+
+
+ + {/* Tools and content for the active building */} + {isBuildingActive && ( +
+ + + +
+ )}
); } -// ============================================================================ -// MAIN SITE PANEL -// ============================================================================ - export function SitePanel() { + const nodes = useScene((state) => state.nodes); + const rootNodeIds = useScene((state) => state.rootNodeIds); + const updateNode = useScene((state) => state.updateNode); + const selectedBuildingId = useViewer((state) => state.selection.buildingId); + const setSelection = useViewer((state) => state.setSelection); const phase = useEditor((state) => state.phase); + const setPhase = useEditor((state) => state.setPhase); - if (phase === "site") { - return ; - } + const [siteCameraOpen, setSiteCameraOpen] = useState(false); + const [buildingCameraOpen, setBuildingCameraOpen] = useState(null); - return ; + const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null; + const buildings = (siteNode?.type === 'site' ? siteNode.children : []) + .map((child) => { + const id = typeof child === 'string' ? child : child.id; + return nodes[id] as BuildingNode | undefined; + }) + .filter((node): node is BuildingNode => node?.type === "building"); + + return ( +
+ {/* Site Header */} + {siteNode && ( +
setPhase("site")} + > +
+ Site + {siteNode.name || "Site"} +
+ +
+ )} + +
+ {/* When phase is site, show property line immediately under site header */} + {phase === "site" && } + + {/* Buildings List */} + {buildings.length === 0 ? ( +
+ No buildings yet +
+ ) : ( +
+ {buildings.map((building) => { + const isBuildingActive = (phase === "structure" || phase === "furnish") && selectedBuildingId === building.id; + + return ( + + ); + })} +
+ )} +
+
+ ); } diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx new file mode 100644 index 00000000..78e59afd --- /dev/null +++ b/apps/editor/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx @@ -0,0 +1,98 @@ +import { useScene, type AnyNode } from "@pascal-app/core"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Pencil } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface InlineRenameInputProps { + node: AnyNode; + isEditing: boolean; + onStopEditing: () => void; + defaultName: string; + className?: string; + onStartEditing?: () => void; +} + +export function InlineRenameInput({ + node, + isEditing, + onStopEditing, + defaultName, + className, + onStartEditing, +}: InlineRenameInputProps) { + const updateNode = useScene((s) => s.updateNode); + const [value, setValue] = useState(node.name || ""); + const inputRef = useRef(null); + + useEffect(() => { + if (isEditing) { + setValue(node.name || ""); + // Focus and select all text after a short delay + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, 0); + } + }, [isEditing, node.name]); + + const handleSave = useCallback(() => { + const trimmed = value.trim(); + if (trimmed !== node.name) { + updateNode(node.id, { name: trimmed || undefined }); + } + onStopEditing(); + }, [value, node.id, node.name, updateNode, onStopEditing]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSave(); + } else if (e.key === "Escape") { + e.preventDefault(); + onStopEditing(); + } + }; + + if (!isEditing) { + return ( +
+ + {node.name || defaultName} + + {onStartEditing && ( + + )} +
+ ); + } + + return ( + setValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleSave} + placeholder={defaultName} + className={cn( + "flex-1 w-full bg-transparent text-foreground outline-none border-b border-primary/50 focus:border-primary rounded-none px-0 py-0 m-0 h-auto text-sm leading-none", + className + )} + onClick={(e) => e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + /> + ); +} diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx index c40cc113..8f9c87f5 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx @@ -2,7 +2,7 @@ import { type AnyNodeId, ItemNode } from "@pascal-app/core"; import { useViewer } from "@pascal-app/viewer"; import Image from "next/image"; import { useState } from "react"; -import { RenamePopover } from "./rename-popover"; +import { InlineRenameInput } from "./inline-rename-input"; import { TreeNode, TreeNodeWrapper } from "./tree-node"; import { TreeNodeActions } from "./tree-node-actions"; @@ -22,7 +22,7 @@ interface ItemTreeNodeProps { } export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) { - const [renameOpen, setRenameOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); const [expanded, setExpanded] = useState(true); const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png"; const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id)); @@ -35,7 +35,7 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) { }; const handleDoubleClick = () => { - setRenameOpen(true); + setIsEditing(true); }; const handleMouseEnter = () => { @@ -50,32 +50,33 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) { const hasChildren = node.children && node.children.length > 0; return ( - } + label={ + setIsEditing(false)} + onStartEditing={() => setIsEditing(true)} + defaultName={defaultName} + /> + } + depth={depth} + hasChildren={hasChildren} + expanded={expanded} + onToggle={() => setExpanded(!expanded)} + onClick={handleClick} + onDoubleClick={handleDoubleClick} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + isSelected={isSelected} + isHovered={isHovered} + isVisible={node.visible !== false} + actions={} > - } - label={node.name || defaultName} - depth={depth} - hasChildren={hasChildren} - expanded={expanded} - onToggle={() => setExpanded(!expanded)} - onClick={handleClick} - onDoubleClick={handleDoubleClick} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} - isSelected={isSelected} - isHovered={isHovered} - isVisible={node.visible !== false} - actions={} - > - {hasChildren && node.children.map((childId) => ( - - ))} - - + {hasChildren && node.children.map((childId) => ( + + ))} + ); } diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/level-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/level-tree-node.tsx index 43d67bf8..1bab93de 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/level-tree-node.tsx @@ -2,7 +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 { InlineRenameInput } from "./inline-rename-input"; import { TreeNode, TreeNodeWrapper } from "./tree-node"; import { TreeNodeActions } from "./tree-node-actions"; @@ -13,7 +13,7 @@ interface LevelTreeNodeProps { export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) { const [expanded, setExpanded] = useState(true); - const [renameOpen, setRenameOpen] = useState(false); + const [isEditing, setIsEditing] = 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); @@ -23,35 +23,36 @@ export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) { }; const handleDoubleClick = () => { - setRenameOpen(true); + setIsEditing(true); }; const defaultName = `Level ${node.level}`; return ( - } + label={ + setIsEditing(false)} + onStartEditing={() => setIsEditing(true)} + defaultName={defaultName} + /> + } + depth={depth} + hasChildren={node.children.length > 0} + expanded={expanded} + onToggle={() => setExpanded(!expanded)} + onClick={handleClick} + onDoubleClick={handleDoubleClick} + isSelected={isSelected} + isHovered={isHovered} + actions={} > - } - 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={} - > - {node.children.map((childId) => ( - - ))} - - + {node.children.map((childId) => ( + + ))} + ); } diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/rename-popover.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/rename-popover.tsx deleted file mode 100644 index 762a75d6..00000000 --- a/apps/editor/components/ui/sidebar/panels/site-panel/rename-popover.tsx +++ /dev/null @@ -1,94 +0,0 @@ -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(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 ( - - {children} - e.preventDefault()} - > -
- 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" - /> - - -
-
-
- ); -} diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx index 026020bf..371fe068 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx @@ -2,7 +2,7 @@ import { RoofNode } from "@pascal-app/core"; import { useViewer } from "@pascal-app/viewer"; import Image from "next/image"; import { useState } from "react"; -import { RenamePopover } from "./rename-popover"; +import { InlineRenameInput } from "./inline-rename-input"; import { TreeNodeWrapper } from "./tree-node"; import { TreeNodeActions } from "./tree-node-actions"; @@ -12,7 +12,7 @@ interface RoofTreeNodeProps { } export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) { - const [renameOpen, setRenameOpen] = useState(false); + const [isEditing, setIsEditing] = 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); @@ -23,7 +23,7 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) { }; const handleDoubleClick = () => { - setRenameOpen(true); + setIsEditing(true); }; const handleMouseEnter = () => { @@ -40,28 +40,29 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) { const defaultName = `Roof (${sizeLabel})`; return ( - - } - label={node.name || defaultName} - depth={depth} - hasChildren={false} - expanded={false} - onToggle={() => {}} - onClick={handleClick} - onDoubleClick={handleDoubleClick} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} - isSelected={isSelected} - isHovered={isHovered} - isVisible={node.visible !== false} - actions={} - /> - + } + label={ + setIsEditing(false)} + onStartEditing={() => setIsEditing(true)} + defaultName={defaultName} + /> + } + depth={depth} + hasChildren={false} + expanded={false} + onToggle={() => {}} + onClick={handleClick} + onDoubleClick={handleDoubleClick} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + isSelected={isSelected} + isHovered={isHovered} + isVisible={node.visible !== false} + actions={} + /> ); } diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx index 9c222102..d720ac86 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx @@ -2,7 +2,7 @@ import { SlabNode } from "@pascal-app/core"; import { useViewer } from "@pascal-app/viewer"; import Image from "next/image"; import { useState } from "react"; -import { RenamePopover } from "./rename-popover"; +import { InlineRenameInput } from "./inline-rename-input"; import { TreeNodeWrapper } from "./tree-node"; import { TreeNodeActions } from "./tree-node-actions"; @@ -12,7 +12,7 @@ interface SlabTreeNodeProps { } export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) { - const [renameOpen, setRenameOpen] = useState(false); + const [isEditing, setIsEditing] = 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); @@ -23,7 +23,7 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) { }; const handleDoubleClick = () => { - setRenameOpen(true); + setIsEditing(true); }; const handleMouseEnter = () => { @@ -39,29 +39,30 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) { const defaultName = `Slab (${area}m²)`; return ( - - } - label={node.name || defaultName} - depth={depth} - hasChildren={false} - expanded={false} - onToggle={() => {}} - onClick={handleClick} - onDoubleClick={handleDoubleClick} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} - isSelected={isSelected} - isHovered={isHovered} - isVisible={node.visible !== false} - actions={} - /> - + } + label={ + setIsEditing(false)} + onStartEditing={() => setIsEditing(true)} + defaultName={defaultName} + /> + } + depth={depth} + hasChildren={false} + expanded={false} + onToggle={() => {}} + onClick={handleClick} + onDoubleClick={handleDoubleClick} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + isSelected={isSelected} + isHovered={isHovered} + isVisible={node.visible !== false} + actions={} + /> ); } diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx index 52d3cc45..63160dcd 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx @@ -42,7 +42,7 @@ export function TreeNodeActions({ node }: TreeNodeActionsProps) { return (