UX polish: selection behavior, styling, and site panel improvements (#119)

* Remove .env.local guard; add portless dep

Remove the check that blocked a local .env.local in apps/editor's dev script and simplify the dev startup to source the root .env. Add portless@^0.4.2 to the project dependencies in package.json. Lockfile updated accordingly.

* Improve tool cursors, previews, and contextual tools

Revamp editor tool visuals and behaviour: replace mesh cursors with a new CursorSphere Group (adds ground marker, vertical indicator and optional tool icon tooltip), unify preview colors to #818cf8, add ground-level previews and ghost lines for ceiling and roof tools, and update slab/zone/wall visuals to match. Add automatic build-mode switching and contextual highlighting for structure tools via a new useContextualTools hook, and relax action-menu visibility checks so tool rows are shown even when not in build mode. Minor typing/import adjustments and small opacity/placement tweaks across multiple tool components.

* Keep tools active and refine action-menu styles

Stop auto-deactivating tools in RoofTool and ZoneTool by removing setTool(null) (and setMode('select') in roof). Add hasActiveTool checks and small layout padding to FurnishTools and StructureTools, and update button classes to improve visual emphasis: smoother transitions, stronger active rings/scale, and dimming/grayscale of non-active tools when another tool is active. These changes improve UX by keeping the selected tool active and giving clearer visual feedback in the action menu.

* Improve selection behavior and refresh UI styling

SelectionManager: add global enter/leave/double-click handlers to support cross-phase hovering and auto-switching into structure/furnish on double-click; normalize click handling (explicitly coalesce shiftKey). Remove per-strategy enter/leave listeners.

UI panels & controls: unify and modernize styles across many panels (ceiling, door, item, reference, roof, slab, wall, window) and action buttons — rounded-md, neutral borders, subtle shadows, translucent headers and consistent hover/focus states. Update view toggles icons and toggle styling.

NumberInput: integrate @number-flow/react NumberFlow for formatted value display, add visual drag progress indicator and refine input/label interactions and styling.

Sidebar: replace rename-popover with inline-rename-input and update tree nodes to use inline editing. Add slider-demo and tweak slider implementation. Update package.json accordingly.

* Use Barlow font across editor UI

Add the Google Barlow font and expose it as a CSS variable, update theme font variables in globals.css, and apply the new font across the editor UI. Imports: add Barlow in app/layout.tsx and include its variable on <html>, set body to use font-sans. Apply font-barlow or font-mono classes to headings, labels, numeric readouts and buttons across multiple panels and primitives to standardize typography. Also update button base variant to include font-barlow. Remove the PhaseSwitcher component and its import/usage from the action menu.

* Replace button wrapper with div in LevelItem

Replace the outer <button> around the LevelItem content with a <div> to avoid nesting interactive button elements (and the resulting invalid HTML/interaction issues). Preserves the original classes and onClick/onDoubleClick handlers so behavior remains the same and allows the PopoverTrigger/button children to function correctly.

* Refine site panel list styles and spacing

Update the SitePanel and TreeNode UI to use compact, border-bottom list rows instead of rounded, card-like items. Simplified active/hover states to use bg-accent variants and reduced shadow/ring styles for a cleaner look. Adjusted paddings (pl/px/py) and container layouts (many sections switched to flex-col) and increased tree node indent/paddingRight for consistent alignment. These changes unify spacing and visual hierarchy across levels, buildings, zones and tree nodes.

* Add auto-scroll to selected items and refine icon styles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-02-25 18:05:02 -05:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 94e5f37983
commit 48d5091bb3
51 changed files with 1648 additions and 1204 deletions
+9 -3
View File
@@ -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);
+10 -2
View File
@@ -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 (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} ${barlow.variable}`}>
<body className="font-sans">
<UsernameGate>{children}</UsernameGate>
<Analytics />
<SpeedInsights />
@@ -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 <EditorOutlinerSync />;
};
@@ -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<Mesh>(null)
const gridCursorRef = useRef<Mesh>(null)
const cursorRef = useRef<Group>(null)
const gridCursorRef = useRef<Group>(null)
const mainLineRef = useRef<Line>(null!)
const closingLineRef = useRef<Line>(null!)
const groundMainLineRef = useRef<Line>(null!)
const groundClosingLineRef = useRef<Line>(null!)
const verticalLineRef = useRef<Line>(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 */}
<mesh ref={gridCursorRef} rotation={[-Math.PI / 2, 0, 0]} renderOrder={2}>
<ringGeometry args={[0.15, 0.2, 32]} />
<meshBasicMaterial color="#a3a3a3" side={DoubleSide} depthTest={false} depthWrite={true} />
<meshBasicMaterial color="#818cf8" side={DoubleSide} depthTest={false} depthWrite={true} opacity={0.5} transparent />
</mesh>
{/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */}
{/* @ts-ignore */}
<line ref={verticalLineRef} geometry={verticalGeo} renderOrder={1}>
<lineBasicNodeMaterial color="#a3a3a3" opacityNode={gradientOpacityNode} depthTest={false} depthWrite={false} transparent />
<lineBasicNodeMaterial color="#818cf8" opacityNode={gradientOpacityNode} depthTest={false} depthWrite={false} transparent />
</line>
{/* Preview fill */}
{/* Preview fill (Top) */}
{previewShape && (
<mesh
frustumCulled={false}
@@ -295,9 +315,27 @@ export const CeilingTool: React.FC = () => {
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#d4d4d4"
color="#818cf8"
depthTest={false}
opacity={0.3}
opacity={0.15}
side={DoubleSide}
transparent
/>
</mesh>
)}
{/* Preview fill (Ground) */}
{previewShape && (
<mesh
frustumCulled={false}
position={[0, levelY + GRID_OFFSET, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
opacity={0.1}
side={DoubleSide}
transparent
/>
@@ -308,7 +346,7 @@ export const CeilingTool: React.FC = () => {
{/* @ts-ignore */}
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry />
<lineBasicNodeMaterial color="#a3a3a3" linewidth={3} depthTest={false} depthWrite={false} />
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} />
</line>
{/* Closing line */}
@@ -316,7 +354,7 @@ export const CeilingTool: React.FC = () => {
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry />
<lineBasicNodeMaterial
color="#a3a3a3"
color="#818cf8"
linewidth={2}
depthTest={false}
depthWrite={false}
@@ -325,12 +363,34 @@ export const CeilingTool: React.FC = () => {
/>
</line>
{/* Ground main line */}
{/* @ts-ignore */}
<line ref={groundMainLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry />
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} opacity={0.3} transparent />
</line>
{/* Ground closing line */}
{/* @ts-ignore */}
<line ref={groundClosingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
linewidth={2}
depthTest={false}
depthWrite={false}
opacity={0.15}
transparent
/>
</line>
{/* Point markers */}
{points.map(([x, z], index) => (
<CursorSphere
key={index}
position={[x, levelY + CEILING_HEIGHT + 0.01, z]}
color={index === 0 ? '#22c55e' : undefined}
color="#818cf8"
showTooltip={false}
/>
))}
</group>
+36 -34
View File
@@ -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<Group>(null)
const outlineRef = useRef<Line>(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 (
<group>
{/* Outline showing rectangle being drawn */}
{/* Cursor at ground height */}
<CursorSphere ref={cursorRef} />
{/* Outline showing rectangle being drawn (Ground) */}
{/* @ts-ignore */}
<line ref={outlineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry />
<lineBasicNodeMaterial color="#8b4513" linewidth={2} depthTest={false} depthWrite={false} />
<lineBasicNodeMaterial color="#818cf8" linewidth={2} depthTest={false} depthWrite={false} opacity={0.3} transparent />
</line>
{/* First corner marker */}
{corner1 && (
<mesh position={[corner1[0], levelY + 0.02, corner1[2]]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={2}>
<ringGeometry args={[0.1, 0.15, 32]} />
<meshBasicMaterial color="#22c55e" depthTest={false} depthWrite={true} />
</mesh>
<CursorSphere
position={[corner1[0], levelY + GRID_OFFSET, corner1[2]]}
color="#818cf8"
showTooltip={false}
/>
)}
{/* Cursor marker on ground */}
<mesh
position={[cursorPosition[0], cursorPosition[1] + 0.02, cursorPosition[2]]}
rotation={[-Math.PI / 2, 0, 0]}
renderOrder={2}
>
<ringGeometry args={[0.1, 0.15, 32]} />
<meshBasicMaterial color="#8b4513" depthTest={false} depthWrite={true} />
</mesh>
{/* Thin preview fill when drawing */}
{/* Thin preview fill when drawing (Ground) */}
{previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && (
<mesh
position={[previewDimensions.centerX, levelY + 0.01, previewDimensions.centerZ]}
position={[previewDimensions.centerX, levelY + GRID_OFFSET, previewDimensions.centerZ]}
rotation={[-Math.PI / 2, 0, 0]}
>
<planeGeometry args={[previewDimensions.length, previewDimensions.width]} />
<meshBasicMaterial
color="#8b4513"
opacity={0.2}
color="#818cf8"
opacity={0.1}
transparent
side={DoubleSide}
depthTest={false}
@@ -1,20 +1,93 @@
import type { ThreeElements } from '@react-three/fiber'
import { forwardRef } from 'react'
import type { Mesh } from 'three'
import type { Group } from 'three'
import { Html } from '@react-three/drei'
import useEditor from '@/store/use-editor'
import { tools } from '@/components/ui/action-menu/structure-tools'
import { furnishTools } from '@/components/ui/action-menu/furnish-tools'
interface CursorSphereProps extends Omit<ThreeElements['mesh'], 'ref'> {
interface CursorSphereProps extends Omit<ThreeElements['group'], 'ref'> {
color?: string
depthWrite?: boolean
showTooltip?: boolean
height?: number
}
export const CursorSphere = forwardRef<Mesh, CursorSphereProps>(function CursorSphere(
{ color = '#f1c066', ...props },
export const CursorSphere = forwardRef<Group, CursorSphereProps>(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 (
<mesh ref={ref} {...props} renderOrder={2}>
<sphereGeometry args={[0.1, 16, 16]} />
<meshBasicMaterial color={color} depthTest={false} depthWrite={true} />
<group ref={ref} {...props}>
{/* Flat marker on the ground */}
<group rotation={[-Math.PI / 2, 0, 0]}>
{/* Center dot */}
<mesh renderOrder={2}>
<circleGeometry args={[0.06, 32]} />
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} transparent opacity={0.9} />
</mesh>
{/* Outer ring / glow */}
<mesh renderOrder={2}>
<circleGeometry args={[0.2, 32]} />
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} transparent opacity={0.25} />
</mesh>
</group>
{/* Vertical line */}
{height > 0 && (
<mesh position={[0, height / 2, 0]} renderOrder={2}>
<cylinderGeometry args={[0.01, 0.01, height, 8]} />
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} transparent opacity={0.7} />
</mesh>
)}
{/* Tool Icon Tooltip at the top of the line */}
{showTooltip && activeToolConfig && (
<Html
position={[0, height > 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 */}
<img
src={activeToolConfig.iconSrc}
alt={activeToolConfig.label}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))'
}}
/>
</Html>
)}
</group>
)
})
@@ -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<Mesh>(null)
const cursorRef = useRef<Group>(null)
const mainLineRef = useRef<Line>(null!)
const closingLineRef = useRef<Line>(null!)
const currentLevelId = useViewer((state) => state.selection.levelId)
@@ -248,9 +248,9 @@ export const SlabTool: React.FC = () => {
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#a3a3a3"
color="#818cf8"
depthTest={false}
opacity={0.3}
opacity={0.15}
side={DoubleSide}
transparent
/>
@@ -261,7 +261,7 @@ export const SlabTool: React.FC = () => {
{/* @ts-ignore */}
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry />
<lineBasicNodeMaterial color="#737373" linewidth={3} depthTest={false} depthWrite={false} />
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} />
</line>
{/* Closing line */}
@@ -269,7 +269,7 @@ export const SlabTool: React.FC = () => {
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry />
<lineBasicNodeMaterial
color="#737373"
color="#818cf8"
linewidth={2}
depthTest={false}
depthWrite={false}
@@ -280,7 +280,7 @@ export const SlabTool: React.FC = () => {
{/* Point markers */}
{points.map(([x, z], index) => (
<CursorSphere key={index} position={[x, levelY + Y_OFFSET + 0.01, z]} color={index === 0 ? '#22c55e' : undefined} />
<CursorSphere key={index} position={[x, levelY + Y_OFFSET + 0.01, z]} color="#818cf8" showTooltip={false} height={0} />
))}
</group>
)
@@ -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<Mesh>(null)
const cursorRef = useRef<Group>(null)
const wallPreviewRef = useRef<Mesh>(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 = () => {
<mesh ref={wallPreviewRef} visible={false} renderOrder={1}>
<shapeGeometry />
<meshBasicMaterial
color="#3b82f6"
color="#818cf8"
transparent
opacity={0.5}
side={DoubleSide}
@@ -1,7 +1,7 @@
import { emitter, type GridEvent, useScene, ZoneNode, type LevelNode } 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 useEditor from "@/store/use-editor";
import { CursorSphere } from "../shared/cursor-sphere";
@@ -101,7 +101,7 @@ const isValidPoint = (
};
export const ZoneTool: React.FC = () => {
const cursorRef = useRef<Mesh>(null);
const cursorRef = useRef<Group>(null);
const mainLineRef = useRef<Line>(null!);
const closingLineRef = useRef<Line>(null!);
const pointsRef = useRef<Array<[number, number]>>([]);
@@ -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 (
<group>
{/* Cursor */}
<CursorSphere ref={cursorRef} color="#3b82f6" />
<CursorSphere ref={cursorRef} />
{/* Preview fill */}
{previewShape && (
@@ -329,7 +323,7 @@ export const ZoneTool: React.FC = () => {
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#3b82f6"
color="#818cf8"
depthTest={false}
opacity={0.15}
side={DoubleSide}
@@ -343,7 +337,7 @@ export const ZoneTool: React.FC = () => {
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry />
<lineBasicNodeMaterial
color="#3b82f6"
color="#818cf8"
linewidth={3}
depthTest={false}
depthWrite={false}
@@ -355,7 +349,7 @@ export const ZoneTool: React.FC = () => {
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry />
<lineBasicNodeMaterial
color="#3b82f6"
color="#818cf8"
linewidth={2}
depthTest={false}
depthWrite={false}
@@ -367,7 +361,7 @@ export const ZoneTool: React.FC = () => {
{/* Point markers */}
{points.map(([x, z], index) =>
isValidPoint([x, z]) ? (
<CursorSphere key={index} position={[x, levelY + Y_OFFSET + 0.01, z]} color={index === 0 ? "#22c55e" : "#3b82f6"} />
<CursorSphere key={index} position={[x, levelY + Y_OFFSET + 0.01, z]} color="#818cf8" showTooltip={false} height={0} />
) : null
)}
</group>
@@ -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 (
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 px-1">
{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() {
<TooltipTrigger asChild>
<Button
className={cn(
"size-11 rounded-lg transition-all",
isActive && "bg-primary shadow-md shadow-primary/20",
!isActive && "hover:bg-white/10",
"size-11 rounded-lg transition-all duration-300",
isActive && "bg-primary shadow-lg shadow-primary/40 ring-2 ring-primary ring-offset-2 ring-offset-zinc-950 scale-110 z-10",
!isActive && hasActiveTool && "opacity-30 hover:opacity-60 scale-95 grayscale",
!isActive && !hasActiveTool && "opacity-60 hover:opacity-100 hover:bg-white/10 hover:scale-105",
)}
onClick={() => {
if (isActive) {
setActiveTool(null);
setCatalogCategory(null);
setMode("select");
} else {
setCatalogCategory(tool.catalogCategory);
setActiveTool("item");
if (mode !== "build") {
setMode("build");
}
}
}}
size="icon"
variant={isActive ? "default" : "ghost"}
@@ -5,7 +5,6 @@ import { cn } from "@/lib/utils";
import { CameraActions } from "./camera-actions";
import { ControlModes } from "./control-modes";
import { PhaseSwitcher } from "./phase-switcher";
import { StructureTools } from "./structure-tools";
import useEditor from "@/store/use-editor";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
@@ -68,7 +67,7 @@ export function ActionMenu({ className }: { className?: string }) {
</AnimatePresence>
<AnimatePresence>
{phase === "furnish" && mode === "build" && (
{phase === "furnish" && (
<motion.div
className={cn(
"overflow-hidden border-zinc-800",
@@ -106,7 +105,7 @@ export function ActionMenu({ className }: { className?: string }) {
{/* Structure Tools Row - Animated */}
<AnimatePresence>
{phase === "structure" && mode === "build" && (
{phase === "structure" && (
<motion.div
className={cn(
"overflow-hidden border-zinc-800 max-h-20 border-b px-2 py-2",
@@ -142,8 +141,6 @@ export function ActionMenu({ className }: { className?: string }) {
</AnimatePresence>
{/* Control Mode Row - Always visible, centered */}
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
<PhaseSwitcher />
<div className="mx-1 h-5 w-px bg-zinc-700" />
<ControlModes />
<div className="mx-1 h-5 w-px bg-zinc-700" />
<ViewToggles />
@@ -1,90 +0,0 @@
"use client";
import { Button } from "@/components/ui/primitives/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/primitives/tooltip";
import { Building2, Map, Sofa } from "lucide-react";
import { cn } from "@/lib/utils";
import useEditor, { Phase } from "@/store/use-editor";
const editorModes: Array<{
id: Phase;
icon: typeof Map;
label: string;
shortcut: string;
description: string;
color: string;
activeColor: string;
}> = [
{
id: "site",
icon: Map,
label: "Site",
shortcut: "1",
description: "Edit terrain, place buildings, set boundaries",
color: "hover:bg-emerald-500/20 hover:text-emerald-400",
activeColor: "bg-emerald-500/20 text-emerald-400",
},
{
id: "structure",
icon: Building2,
label: "Structure",
shortcut: "2",
description: "Walls, rooms, doors, windows, ...",
color: "hover:bg-blue-500/20 hover:text-blue-400",
activeColor: "bg-blue-500/20 text-blue-400",
},
{
id: "furnish",
icon: Sofa,
label: "Furnish",
shortcut: "3",
description: "Place furniture, appliances, decorations",
color: "hover:bg-amber-500/20 hover:text-amber-400",
activeColor: "bg-amber-500/20 text-amber-400",
},
];
export function PhaseSwitcher() {
const phase = useEditor((state) => state.phase);
const setPhase = useEditor((state) => state.setPhase);
return (
<div className="-my-0.5 flex items-center gap-1 rounded-lg bg-white/5 p-0.5">
{editorModes.map((mode) => {
const Icon = mode.icon;
const isActive = phase === mode.id;
return (
<Tooltip key={mode.id}>
<TooltipTrigger asChild>
<Button
className={cn(
"h-8 w-8 transition-all",
"text-zinc-400",
!isActive && mode.color,
isActive && mode.activeColor
)}
onClick={() => setPhase(mode.id)}
size="icon"
variant="ghost"
>
<Icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">
{mode.label} ({mode.shortcut})
</p>
<p className="text-xs text-zinc-400">{mode.description}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
);
}
@@ -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 }
@@ -29,27 +30,38 @@ export function StructureTools() {
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 (
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 px-1">
{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 (
<Tooltip key={`${tool.id}-${tool.catalogCategory ?? index}`}>
<TooltipTrigger asChild>
<Button
className={cn(
'size-11 rounded-lg transition-all',
isActive && 'bg-primary shadow-md shadow-primary/20',
!isActive && 'hover:bg-white/10',
'size-11 rounded-lg transition-all duration-300',
isActive && 'bg-primary shadow-lg shadow-primary/40 ring-2 ring-primary ring-offset-2 ring-offset-zinc-950 scale-110 z-10',
!isActive && hasActiveTool && 'opacity-30 hover:opacity-60 scale-95 grayscale',
!isActive && !hasActiveTool && isContextual && 'bg-white/5 hover:bg-white/10 hover:scale-105',
!isActive && !hasActiveTool && !isContextual && 'opacity-60 hover:opacity-100 hover:bg-white/10 hover:scale-105',
)}
onClick={() => {
if (isActive) {
@@ -58,6 +70,11 @@ export function StructureTools() {
} else {
setTool(tool.id)
setCatalogCategory(tool.catalogCategory ?? null)
// Automatically switch to build mode if we select a tool
if (useEditor.getState().mode !== 'build') {
useEditor.getState().setMode('build')
}
}
}}
size="icon"
@@ -133,8 +133,8 @@ export function ViewToggles() {
className={cn(
'h-8 w-8 text-zinc-400 transition-all p-0',
wallMode !== 'cutaway'
? 'bg-emerald-500/20 text-emerald-400'
: 'hover:bg-zinc-800',
? 'bg-white/10'
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
)}
onClick={cycleWallMode}
size="icon"
@@ -156,16 +156,16 @@ export function ViewToggles() {
<TooltipTrigger asChild>
<Button
className={cn(
'h-8 w-8 text-zinc-400 transition-all',
'h-8 w-8 text-zinc-400 transition-all p-0',
showScans
? 'bg-cyan-500/20 text-cyan-400'
: 'hover:bg-zinc-800',
? 'bg-white/10'
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
)}
onClick={() => setShowScans(!showScans)}
size="icon"
variant="ghost"
>
<Box className="h-4 w-4" />
<img alt="Scans" className="h-5 w-5 object-contain" src="/icons/mesh.png" />
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -178,16 +178,16 @@ export function ViewToggles() {
<TooltipTrigger asChild>
<Button
className={cn(
'h-8 w-8 text-zinc-400 transition-all',
'h-8 w-8 text-zinc-400 transition-all p-0',
showGuides
? 'bg-purple-500/20 text-purple-400'
: 'hover:bg-zinc-800',
? 'bg-white/10'
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
)}
onClick={() => setShowGuides(!showGuides)}
size="icon"
variant="ghost"
>
<Image className="h-4 w-4" />
<img alt="Guides" className="h-5 w-5 object-contain" src="/icons/floorplan.png" />
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -119,16 +119,16 @@ export function CeilingPanel() {
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 justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex items-center gap-2 min-w-0">
<Image src="/icons/ceiling.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
<h2 className="font-semibold text-foreground text-sm truncate">
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || `Ceiling (${area.toFixed(1)}m²)`}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
onClick={handleClose}
>
<X className="h-4 w-4" />
@@ -140,7 +140,7 @@ export function CeilingPanel() {
<div className="space-y-4">
{/* Height */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Height
</label>
<div className="flex items-center gap-2">
@@ -162,27 +162,27 @@ export function CeilingPanel() {
{/* Quick preset buttons */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Presets
</label>
<div className="grid grid-cols-3 gap-2">
<button
type="button"
className="rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => handleUpdate({ height: 2.4 })}
>
Low (2.4m)
</button>
<button
type="button"
className="rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => handleUpdate({ height: 2.5 })}
>
Standard (2.5m)
</button>
<button
type="button"
className="rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => handleUpdate({ height: 3.0 })}
>
High (3m)
@@ -192,10 +192,10 @@ export function CeilingPanel() {
{/* Area info */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Area
</label>
<div className="rounded border border-border bg-muted/50 px-3 py-2 text-sm">
<div className="rounded-lg border border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-3 py-2 text-sm text-foreground">
{area.toFixed(2)} m²
</div>
</div>
@@ -203,13 +203,13 @@ export function CeilingPanel() {
{/* Holes */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Holes
</label>
{editingHole?.nodeId === selectedId ? (
<button
type="button"
className="flex items-center gap-1 rounded border border-green-500 bg-green-500/10 px-2 py-1 text-xs text-green-600 hover:bg-green-500/20 cursor-pointer"
className="flex items-center gap-1 rounded-md border border-green-500 bg-green-500/10 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium text-green-600 hover:bg-green-500/20 transition-colors cursor-pointer"
onClick={() => setEditingHole(null)}
>
<span>Done Editing</span>
@@ -217,7 +217,7 @@ export function CeilingPanel() {
) : (
<button
type="button"
className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs hover:bg-accent cursor-pointer"
className="flex items-center gap-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleAddHole}
>
<Plus className="h-3 w-3" />
@@ -233,10 +233,10 @@ export function CeilingPanel() {
return (
<div
key={index}
className={`flex items-center justify-between rounded border px-3 py-2 ${
className={`flex items-center justify-between rounded-lg border px-3 py-2 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] transition-colors ${
isEditing
? 'border-green-500 bg-green-500/10'
: 'border-border bg-muted/30'
? 'border-green-500 bg-green-500/10 ring-1 ring-green-500/20'
: 'border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30'
}`}
>
<div className="flex-1 min-w-0">
+26 -26
View File
@@ -138,16 +138,16 @@ export function DoorPanel() {
return (
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-82 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md max-h-[calc(100dvh-100px)]">
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b p-3">
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex items-center gap-2 min-w-0">
<Image src="/icons/door.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
<h2 className="font-semibold text-foreground text-sm truncate">
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || `Door (${node.width}×${node.height}m)`}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
onClick={handleClose}
>
<X className="h-4 w-4" />
@@ -159,7 +159,7 @@ export function DoorPanel() {
{/* Position */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Position
</label>
<div className="grid grid-cols-1 gap-2">
@@ -172,7 +172,7 @@ export function DoorPanel() {
</div>
<button
type="button"
className="w-full flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleFlip}
>
<FlipHorizontal2 className="h-3.5 w-3.5" />
@@ -182,7 +182,7 @@ export function DoorPanel() {
{/* Dimensions */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Dimensions
</label>
<div className="grid grid-cols-2 gap-2">
@@ -213,7 +213,7 @@ export function DoorPanel() {
{/* Frame */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Frame
</label>
<div className="grid grid-cols-2 gap-2">
@@ -246,7 +246,7 @@ export function DoorPanel() {
{/* Content Padding */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Content Padding
</label>
<div className="grid grid-cols-2 gap-2">
@@ -279,22 +279,22 @@ export function DoorPanel() {
{/* Swing */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Swing
</label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Hinges</span>
<div className="flex gap-1">
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg">
{(['left', 'right'] as const).map((side) => (
<button
key={side}
type="button"
onClick={() => handleUpdate({ hingesSide: side })}
className={`flex-1 rounded border px-2 py-1 text-xs cursor-pointer transition-colors ${
className={`flex-1 rounded-md px-2 py-1 text-xs font-medium cursor-pointer transition-all duration-200 ${
node.hingesSide === side
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-accent'
? 'bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50'
}`}
>
{side.charAt(0).toUpperCase() + side.slice(1)}
@@ -304,7 +304,7 @@ export function DoorPanel() {
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Direction</span>
<div className="flex gap-1">
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg">
{(['inward', 'outward'] as const).map((dir) => (
<button
key={dir}
@@ -327,7 +327,7 @@ export function DoorPanel() {
{/* Threshold */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Threshold
</label>
<Switch
@@ -354,7 +354,7 @@ export function DoorPanel() {
{/* Handle */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Handle
</label>
<Switch
@@ -379,7 +379,7 @@ export function DoorPanel() {
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Side</span>
<div className="flex gap-1">
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg">
{(['left', 'right'] as const).map((side) => (
<button
key={side}
@@ -402,7 +402,7 @@ export function DoorPanel() {
{/* Hardware */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Hardware
</label>
<div className="space-y-2">
@@ -440,7 +440,7 @@ export function DoorPanel() {
{/* Segments */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Leaf segments (top bottom)
</label>
{node.segments.map((seg, i) => {
@@ -451,7 +451,7 @@ export function DoorPanel() {
<div key={i} className="rounded border border-border p-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">Segment {i + 1}</span>
<div className="flex gap-1">
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg">
{(['panel', 'glass', 'empty'] as const).map((t) => (
<button
key={t}
@@ -584,7 +584,7 @@ export function DoorPanel() {
<div className="flex gap-2">
<button
type="button"
className="flex-1 rounded border border-border px-2 py-1 text-xs hover:bg-accent cursor-pointer"
className="flex-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => {
const updated = [
...node.segments,
@@ -598,7 +598,7 @@ export function DoorPanel() {
{node.segments.length > 1 && (
<button
type="button"
className="flex-1 rounded border border-border px-2 py-1 text-xs hover:bg-accent cursor-pointer"
className="flex-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => {
handleUpdate({ segments: node.segments.slice(0, -1) })
}}
@@ -611,11 +611,11 @@ export function DoorPanel() {
</div>
{/* Action Buttons */}
<div className="border-t p-3">
<div className="border-t border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex gap-2">
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleMove}
>
<Move className="h-3.5 w-3.5" />
@@ -623,7 +623,7 @@ export function DoorPanel() {
</button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleDuplicate}
>
<Copy className="h-3.5 w-3.5" />
@@ -631,7 +631,7 @@ export function DoorPanel() {
</button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleDelete}
>
<Trash2 className="h-3.5 w-3.5" />
+12 -12
View File
@@ -84,7 +84,7 @@ export function ItemPanel() {
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 justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex items-center gap-2 min-w-0">
<Image
src={node.asset.thumbnail || '/icons/furniture.png'}
@@ -93,13 +93,13 @@ export function ItemPanel() {
height={16}
className="shrink-0 object-contain"
/>
<h2 className="font-semibold text-foreground text-sm truncate">
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || node.asset.name}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
onClick={handleClose}
>
<X className="h-4 w-4" />
@@ -111,7 +111,7 @@ export function ItemPanel() {
<div className="space-y-4">
{/* Position */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Position
</label>
<div className="grid grid-cols-3 gap-2">
@@ -144,7 +144,7 @@ export function ItemPanel() {
{/* Rotation */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Rotation
</label>
<div className="flex items-center gap-2">
@@ -193,7 +193,7 @@ export function ItemPanel() {
{/* Scale */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Scale
</label>
<button
@@ -251,10 +251,10 @@ export function ItemPanel() {
{/* Dimensions (effective, read-only) */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Dimensions
</label>
<div className="rounded border border-border bg-muted/50 px-3 py-2 text-sm">
<div className="rounded-lg border border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-3 py-2 text-sm font-mono text-foreground">
{(() => {
const [w, h, d] = getScaledDimensions(node)
return `${Math.round(w * 100) / 100}m × ${Math.round(h * 100) / 100}m × ${Math.round(d * 100) / 100}m`
@@ -265,11 +265,11 @@ export function ItemPanel() {
</div>
{/* Action Buttons */}
<div className="border-t p-3">
<div className="border-t border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex gap-2">
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleMove}
>
<Move className="h-3.5 w-3.5" />
@@ -277,7 +277,7 @@ export function ItemPanel() {
</button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleDuplicate}
>
<Copy className="h-3.5 w-3.5" />
@@ -285,7 +285,7 @@ export function ItemPanel() {
</button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleDelete}
>
<Trash2 className="h-3.5 w-3.5" />
@@ -37,19 +37,19 @@ export function ReferencePanel() {
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 justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex items-center gap-2 min-w-0">
{isScan ? (
<Box className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<Image className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<h2 className="font-semibold text-foreground text-sm truncate">
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || (isScan ? '3D Scan' : 'Guide Image')}
</h2>
</div>
<button
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
onClick={handleClose}
>
<X className="h-4 w-4" />
@@ -61,7 +61,7 @@ export function ReferencePanel() {
<div className="space-y-4">
{/* Position */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Position
</label>
<div className="grid grid-cols-3 gap-2">
@@ -83,7 +83,7 @@ export function ReferencePanel() {
{/* Rotation Y */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Rotation
</label>
<div className="flex items-center gap-1.5">
@@ -101,7 +101,7 @@ export function ReferencePanel() {
/>
<span className="text-muted-foreground text-xs shrink-0">&deg;</span>
<button
className="shrink-0 rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent cursor-pointer"
className="shrink-0 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-1.5 py-1 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() =>
handleUpdate({
rotation: [node.rotation[0], node.rotation[1] - Math.PI / 4, node.rotation[2]],
@@ -111,7 +111,7 @@ export function ReferencePanel() {
&minus;45
</button>
<button
className="shrink-0 rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent cursor-pointer"
className="shrink-0 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-1.5 py-1 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() =>
handleUpdate({
rotation: [node.rotation[0], node.rotation[1] + Math.PI / 4, node.rotation[2]],
@@ -125,7 +125,7 @@ export function ReferencePanel() {
{/* Scale */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Scale
</label>
<NumberInput
@@ -144,10 +144,10 @@ export function ReferencePanel() {
{/* Opacity */}
<div className="space-y-2">
<div className="flex justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Opacity
</label>
<span className="text-muted-foreground text-xs">{node.opacity}%</span>
<span className="text-muted-foreground font-mono text-xs">{node.opacity}%</span>
</div>
<input
className="w-full cursor-pointer"
+16 -16
View File
@@ -39,16 +39,16 @@ export function RoofPanel() {
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 justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex items-center gap-2 min-w-0">
<Image src="/icons/roof.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
<h2 className="font-semibold text-foreground text-sm truncate">
<h2 className="font-semibold font-barlow 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"
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
onClick={handleClose}
>
<X className="h-4 w-4" />
@@ -60,12 +60,12 @@ export function RoofPanel() {
<div className="space-y-4">
{/* Length */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow 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"
className="flex-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
min="0.5"
onChange={(e) => {
const value = Number.parseFloat(e.target.value)
@@ -83,12 +83,12 @@ export function RoofPanel() {
{/* Height */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow 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"
className="flex-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
min="0.1"
onChange={(e) => {
const value = Number.parseFloat(e.target.value)
@@ -107,7 +107,7 @@ export function RoofPanel() {
{/* 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">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Slope Widths
</label>
<span className="text-muted-foreground text-xs">
@@ -119,7 +119,7 @@ export function RoofPanel() {
<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"
className="w-full rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
min="0.1"
onChange={(e) => {
const value = Number.parseFloat(e.target.value)
@@ -138,7 +138,7 @@ export function RoofPanel() {
<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"
className="w-full rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
min="0.1"
onChange={(e) => {
const value = Number.parseFloat(e.target.value)
@@ -158,12 +158,12 @@ export function RoofPanel() {
{/* Rotation */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow 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"
className="min-w-0 flex-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
onChange={(e) => {
const degrees = Number.parseFloat(e.target.value)
if (!Number.isNaN(degrees)) {
@@ -178,7 +178,7 @@ export function RoofPanel() {
<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"
className="shrink-0 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-1.5 py-1 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => {
const newRotation = node.rotation - Math.PI / 2
handleUpdate({ rotation: newRotation })
@@ -188,7 +188,7 @@ export function RoofPanel() {
</button>
<button
type="button"
className="shrink-0 rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent cursor-pointer"
className="shrink-0 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-1.5 py-1 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => {
const newRotation = node.rotation + Math.PI / 2
handleUpdate({ rotation: newRotation })
@@ -201,7 +201,7 @@ export function RoofPanel() {
{/* Position */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Position
</label>
<div className="grid grid-cols-3 gap-2">
@@ -209,7 +209,7 @@ export function RoofPanel() {
<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"
className="w-full rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
onChange={(e) => {
const value = Number.parseFloat(e.target.value)
if (!Number.isNaN(value)) {
+17 -17
View File
@@ -119,16 +119,16 @@ export function SlabPanel() {
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 justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex items-center gap-2 min-w-0">
<Image src="/icons/floor.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
<h2 className="font-semibold text-foreground text-sm truncate">
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || `Slab (${area.toFixed(1)}m²)`}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
onClick={handleClose}
>
<X className="h-4 w-4" />
@@ -140,7 +140,7 @@ export function SlabPanel() {
<div className="space-y-4">
{/* Elevation */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Elevation
</label>
<div className="flex items-center gap-2">
@@ -162,34 +162,34 @@ export function SlabPanel() {
{/* Quick preset buttons */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Presets
</label>
<div className="grid grid-cols-4 gap-2">
<button
type="button"
className="rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => handleUpdate({ elevation: -0.15 })}
>
Sunken (-15cm)
</button>
<button
type="button"
className="rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => handleUpdate({ elevation: 0 })}
>
Ground (0m)
</button>
<button
type="button"
className="rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => handleUpdate({ elevation: 0.05 })}
>
Raised (5cm)
</button>
<button
type="button"
className="rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => handleUpdate({ elevation: 0.15 })}
>
Step (15cm)
@@ -199,10 +199,10 @@ export function SlabPanel() {
{/* Area info */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Area
</label>
<div className="rounded border border-border bg-muted/50 px-3 py-2 text-sm">
<div className="rounded-lg border border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-3 py-2 text-sm text-foreground">
{area.toFixed(2)} m²
</div>
</div>
@@ -210,13 +210,13 @@ export function SlabPanel() {
{/* Holes */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Holes
</label>
{editingHole?.nodeId === selectedId ? (
<button
type="button"
className="flex items-center gap-1 rounded border border-green-500 bg-green-500/10 px-2 py-1 text-xs text-green-600 hover:bg-green-500/20 cursor-pointer"
className="flex items-center gap-1 rounded-md border border-green-500 bg-green-500/10 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium text-green-600 hover:bg-green-500/20 transition-colors cursor-pointer"
onClick={() => setEditingHole(null)}
>
<span>Done Editing</span>
@@ -224,7 +224,7 @@ export function SlabPanel() {
) : (
<button
type="button"
className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs hover:bg-accent cursor-pointer"
className="flex items-center gap-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleAddHole}
>
<Plus className="h-3 w-3" />
@@ -240,10 +240,10 @@ export function SlabPanel() {
return (
<div
key={index}
className={`flex items-center justify-between rounded border px-3 py-2 ${
className={`flex items-center justify-between rounded-lg border px-3 py-2 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] transition-colors ${
isEditing
? 'border-green-500 bg-green-500/10'
: 'border-border bg-muted/30'
? 'border-green-500 bg-green-500/10 ring-1 ring-green-500/20'
: 'border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30'
}`}
>
<div className="flex-1 min-w-0">
@@ -43,16 +43,16 @@ export function WallPanel() {
return (
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-64 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 justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex items-center gap-2 min-w-0">
<Image src="/icons/wall.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
<h2 className="font-semibold text-foreground text-sm truncate">
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || `Wall (${length.toFixed(2)}m)`}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
onClick={handleClose}
>
<X className="h-4 w-4" />
@@ -64,7 +64,7 @@ export function WallPanel() {
{/* Dimensions */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Dimensions
</label>
<div className="flex items-center gap-1.5">
@@ -95,10 +95,10 @@ export function WallPanel() {
{/* Info */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Info
</label>
<div className="rounded border border-border bg-muted/50 px-3 py-2 text-sm">
<div className="rounded-lg border border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-3 py-2 text-sm font-mono text-foreground">
Length: {length.toFixed(2)} m
</div>
</div>
@@ -127,16 +127,16 @@ export function WindowPanel() {
return (
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-82 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 justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex items-center gap-2 min-w-0">
<Image src="/icons/window.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
<h2 className="font-semibold text-foreground text-sm truncate">
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || `Window (${node.width}×${node.height}m)`}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
onClick={handleClose}
>
<X className="h-4 w-4" />
@@ -148,7 +148,7 @@ export function WindowPanel() {
{/* Position */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Position
</label>
<div className="grid grid-cols-2 gap-2">
@@ -167,7 +167,7 @@ export function WindowPanel() {
</div>
<button
type="button"
className="w-full flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleFlip}
>
<FlipHorizontal2 className="h-3.5 w-3.5" />
@@ -177,7 +177,7 @@ export function WindowPanel() {
{/* Dimensions */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Dimensions
</label>
<div className="grid grid-cols-2 gap-2">
@@ -208,7 +208,7 @@ export function WindowPanel() {
{/* Frame */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Frame
</label>
<div className="grid grid-cols-2 gap-2">
@@ -241,7 +241,7 @@ export function WindowPanel() {
{/* Grid */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Grid
</label>
<div className="grid grid-cols-2 gap-2">
@@ -343,7 +343,7 @@ export function WindowPanel() {
{/* Sill */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Sill
</label>
<Switch
@@ -383,11 +383,11 @@ export function WindowPanel() {
</div>
{/* Action Buttons */}
<div className="border-t p-3">
<div className="border-t border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex gap-2">
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleMove}
>
<Move className="h-3.5 w-3.5" />
@@ -395,7 +395,7 @@ export function WindowPanel() {
</button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleDuplicate}
>
<Copy className="h-3.5 w-3.5" />
@@ -403,7 +403,7 @@ export function WindowPanel() {
</button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleDelete}
>
<Trash2 className="h-3.5 w-3.5" />
@@ -5,7 +5,7 @@ import type * as React from 'react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-barlow font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
@@ -45,7 +45,7 @@ function ContextMenuSubTrigger({
return (
<ContextMenuPrimitive.SubTrigger
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
data-inset={inset}
@@ -104,7 +104,7 @@ function ContextMenuItem({
return (
<ContextMenuPrimitive.Item
className={cn(
"data-[variant=destructive]:*:[svg]:!text-destructive relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
"data-[variant=destructive]:*:[svg]:!text-destructive relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
data-inset={inset}
@@ -125,7 +125,7 @@ function ContextMenuCheckboxItem({
<ContextMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
data-slot="context-menu-checkbox-item"
@@ -149,7 +149,7 @@ function ContextMenuRadioItem({
return (
<ContextMenuPrimitive.RadioItem
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
data-slot="context-menu-radio-item"
@@ -174,7 +174,7 @@ function ContextMenuLabel({
}) {
return (
<ContextMenuPrimitive.Label
className={cn('px-2 py-1.5 font-medium text-foreground text-sm data-[inset]:pl-8', className)}
className={cn('px-2 py-1.5 font-medium font-barlow text-foreground text-sm data-[inset]:pl-8', className)}
data-inset={inset}
data-slot="context-menu-label"
{...props}
@@ -58,7 +58,7 @@ function DropdownMenuItem({
return (
<DropdownMenuPrimitive.Item
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-8 data-[variant=destructive]:text-destructive data-disabled:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 data-[variant=destructive]:*:[svg]:text-destructive!",
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-8 data-[variant=destructive]:text-destructive data-disabled:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 data-[variant=destructive]:*:[svg]:text-destructive!",
className,
)}
data-inset={inset}
@@ -79,7 +79,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
data-slot="dropdown-menu-checkbox-item"
@@ -109,7 +109,7 @@ function DropdownMenuRadioItem({
return (
<DropdownMenuPrimitive.RadioItem
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
data-slot="dropdown-menu-radio-item"
@@ -134,7 +134,7 @@ function DropdownMenuLabel({
}) {
return (
<DropdownMenuPrimitive.Label
className={cn('px-2 py-1.5 font-medium text-sm data-inset:pl-8', className)}
className={cn('px-2 py-1.5 font-medium font-barlow text-sm data-inset:pl-8', className)}
data-inset={inset}
data-slot="dropdown-menu-label"
{...props}
@@ -180,7 +180,7 @@ function DropdownMenuSubTrigger({
return (
<DropdownMenuPrimitive.SubTrigger
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-inset:pl-8 data-[state=open]:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm font-barlow outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-inset:pl-8 data-[state=open]:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
data-inset={inset}
@@ -2,6 +2,7 @@
import { useScene } from '@pascal-app/core'
import { useCallback, useRef, useState } from 'react'
import NumberFlow from '@number-flow/react'
interface NumberInputProps {
label: string
@@ -29,7 +30,7 @@ export function NumberInput({
const [inputValue, setInputValue] = useState(value.toFixed(precision))
const startXRef = useRef(0)
const startValueRef = useRef(0)
const labelRef = useRef<HTMLLabelElement>(null)
const labelRef = useRef<HTMLDivElement>(null)
const clamp = useCallback(
(val: number) => {
@@ -131,22 +132,32 @@ export function NumberInput({
)
return (
<div className={`${className}`}>
<div className="flex items-center rounded border border-input bg-muted/30 overflow-hidden">
<label
<div className={`${className} relative group/input`}>
<div
className={`absolute inset-y-0 left-0 bg-primary/10 dark:bg-primary/20 pointer-events-none transition-all duration-75 ${isDragging ? 'opacity-100' : 'opacity-0'}`}
style={{
width: `${Math.min(100, Math.max(0, ((value - (min ?? Math.min(0, value))) / ((max ?? Math.max(10, value)) - (min ?? Math.min(0, value)))) * 100))}%`,
borderTopRightRadius: value >= (max ?? Math.max(10, value)) ? '0.5rem' : '0',
borderBottomRightRadius: value >= (max ?? Math.max(10, value)) ? '0.5rem' : '0',
borderTopLeftRadius: '0.5rem',
borderBottomLeftRadius: '0.5rem',
}}
/>
<div className={`flex items-center rounded-lg border shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] overflow-hidden transition-all focus-within:ring-1 focus-within:ring-primary focus-within:border-primary relative z-10 ${isDragging ? 'bg-transparent border-neutral-300 dark:border-border ring-1 ring-neutral-200/60 dark:ring-border/50' : 'bg-white dark:bg-accent/30 border-neutral-200/60 dark:border-border/50 hover:border-neutral-300 dark:hover:border-border/80'}`}>
<div
ref={labelRef}
className={`px-2 py-1 text-muted-foreground text-xs select-none ${
isDragging ? 'cursor-ew-resize' : 'hover:cursor-ew-resize hover:text-foreground'
className={`pl-2 pr-1 py-1.5 text-muted-foreground text-xs select-none font-barlow font-medium truncate z-10 ${
isDragging ? 'cursor-ew-resize text-foreground' : 'hover:cursor-ew-resize hover:text-foreground'
} transition-colors`}
onMouseDown={handleLabelMouseDown}
>
{label}
</label>
</div>
{isEditing ? (
<input
autoFocus
size={1}
className="flex-1 min-w-0 bg-transparent px-2 py-1 text-foreground text-sm outline-none text-right"
className="flex-1 min-w-0 bg-transparent px-2 py-1.5 text-foreground text-sm font-mono font-medium outline-none text-right placeholder:text-muted-foreground/50 z-10"
onBlur={handleInputBlur}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
@@ -155,10 +166,13 @@ export function NumberInput({
/>
) : (
<div
className="flex-1 px-2 py-1 text-foreground text-sm cursor-text hover:bg-muted/50 transition-colors text-right"
className={`flex-1 px-2 py-1.5 text-sm font-mono font-medium cursor-text hover:bg-black/5 dark:hover:bg-white/5 transition-colors text-right truncate z-10 text-foreground tabular-nums tracking-tight min-w-0`}
onClick={handleValueClick}
>
{value.toFixed(precision)}
<NumberFlow
value={Number(value.toFixed(precision))}
format={{ minimumFractionDigits: precision, maximumFractionDigits: precision }}
/>
</div>
)}
</div>
@@ -404,7 +404,7 @@ function SidebarGroupLabel({
return (
<Slot
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 font-medium text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"flex h-8 shrink-0 items-center rounded-md px-2 font-medium font-barlow text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
@@ -419,7 +419,7 @@ function SidebarGroupLabel({
return (
<div
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 font-medium text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"flex h-8 shrink-0 items-center rounded-md px-2 font-medium font-barlow text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
@@ -504,7 +504,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left font-barlow text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
@@ -735,7 +735,7 @@ function SidebarMenuSubButton({
isActive?: boolean;
}) {
const classes = cn(
"-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 font-barlow text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
@@ -40,7 +40,7 @@ function TooltipContent({
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className={cn(
'fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-foreground px-3 py-1.5 text-background text-xs data-[state=closed]:animate-out',
'fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-foreground px-3 py-1.5 text-background font-barlow text-xs data-[state=closed]:animate-out',
className,
)}
data-slot="tooltip-content"
@@ -30,8 +30,6 @@ export function AppSidebar() {
switch (activePanel) {
case "site":
return "Site";
case "collections":
return "Collections";
case "settings":
return "Settings";
default:
@@ -1,6 +1,6 @@
"use client";
import { Building2, Layers, Settings } from "lucide-react";
import { Building2, Settings } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import {
@@ -10,7 +10,7 @@ import {
} from "@/components/ui/primitives/tooltip";
import { cn } from "@/lib/utils";
export type PanelId = "site" | "collections" | "settings";
export type PanelId = "site" | "settings";
interface IconRailProps {
activePanel: PanelId;
@@ -20,7 +20,6 @@ interface IconRailProps {
const panels: { id: PanelId; icon: typeof Building2; label: string }[] = [
{ id: "site", icon: Building2, label: "Site" },
{ id: "collections", icon: Layers, label: "Collections" },
{ id: "settings", icon: Settings, label: "Settings" },
];
@@ -2,7 +2,7 @@ import { CeilingNode } 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";
@@ -13,7 +13,7 @@ interface CeilingTreeNodeProps {
export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
const [expanded, setExpanded] = useState(false);
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);
@@ -24,7 +24,7 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
};
const handleDoubleClick = () => {
setRenameOpen(true);
setIsEditing(true);
};
const handleMouseEnter = () => {
@@ -40,15 +40,17 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
const defaultName = `Ceiling (${area}m²)`;
return (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper
icon={<Image src="/icons/ceiling.png" alt="" width={14} height={14} className="object-contain" />}
label={node.name || defaultName}
label={
<InlineRenameInput
node={node}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={defaultName}
/>
}
depth={depth}
hasChildren={node.children.length > 0}
expanded={expanded}
@@ -66,7 +68,6 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</TreeNodeWrapper>
</RenamePopover>
);
}
@@ -4,7 +4,7 @@ import { DoorNode } 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"
@@ -14,7 +14,7 @@ interface DoorTreeNodeProps {
}
export function DoorTreeNode({ node, depth }: DoorTreeNodeProps) {
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,21 +23,23 @@ export function DoorTreeNode({ node, depth }: DoorTreeNodeProps) {
const defaultName = `Door (${node.width}×${node.height}m)`
return (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper
icon={<Image src="/icons/door.png" alt="" width={14} height={14} className="object-contain" />}
label={node.name || defaultName}
label={
<InlineRenameInput
node={node}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={defaultName}
/>
}
depth={depth}
hasChildren={false}
expanded={false}
onToggle={() => {}}
onClick={() => setSelection({ selectedIds: [node.id] })}
onDoubleClick={() => setRenameOpen(true)}
onDoubleClick={() => setIsEditing(true)}
onMouseEnter={() => setHoveredId(node.id)}
onMouseLeave={() => setHoveredId(null)}
isSelected={isSelected}
@@ -45,6 +47,5 @@ export function DoorTreeNode({ node, depth }: DoorTreeNodeProps) {
isVisible={node.visible !== false}
actions={<TreeNodeActions node={node} />}
/>
</RenamePopover>
)
}
@@ -1,5 +1,6 @@
import {
type AnyNodeId,
type AnyNode,
type BuildingNode,
emitter,
LevelNode,
@@ -19,12 +20,12 @@ import {
Plus,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import useEditor from "@/store/use-editor";
import { TreeNode } from "./tree-node";
import { ReferencesDialog } from "./references-dialog";
import { RenamePopover } from "./rename-popover";
import { InlineRenameInput } from "./inline-rename-input";
import {
Popover,
PopoverContent,
@@ -307,293 +308,69 @@ function CameraPopover({
);
}
function SitePhaseView() {
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 [siteCameraOpen, setSiteCameraOpen] = useState(false);
const [buildingCameraOpen, setBuildingCameraOpen] = useState<string | null>(null);
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");
function LevelItem({
level,
selectedLevelId,
setSelection,
setReferencesLevelId,
deleteNode,
updateNode,
}: {
level: LevelNode;
selectedLevelId: string | null;
setSelection: (selection: any) => void;
setReferencesLevelId: (id: string | null) => void;
deleteNode: (id: AnyNodeId) => void;
updateNode: (id: AnyNodeId, updates: Partial<AnyNode>) => void;
}) {
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const itemRef = useRef<HTMLDivElement>(null);
const isSelected = selectedLevelId === level.id;
useEffect(() => {
if (isSelected && itemRef.current) {
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [isSelected]);
return (
<div className="flex flex-col h-full">
{/* Site row */}
{siteNode && (
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">{siteNode.name || "Site"}</span>
</div>
<CameraPopover
nodeId={siteNode.id as AnyNodeId}
hasCamera={!!siteNode.camera}
open={siteCameraOpen}
onOpenChange={setSiteCameraOpen}
buttonClassName="hover:bg-accent text-muted-foreground"
<div
ref={itemRef}
className={cn(
"flex items-center group/level border-b border-border/50 pr-2 transition-all duration-200",
isSelected
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
)}
>
<div
className="flex-1 flex items-center gap-2 pl-3 py-2 text-sm cursor-pointer min-w-0"
onClick={() => setSelection({ levelId: level.id })}
onDoubleClick={() => setIsEditing(true)}
>
<Layers className={cn(
"w-3.5 h-3.5 shrink-0 transition-all duration-200",
!isSelected && "opacity-60 grayscale"
)} />
<InlineRenameInput
node={level}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={`Level ${level.level}`}
/>
</div>
)}
<PropertyLineSection />
{buildings.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground">
No buildings yet
</div>
) : (
<div className="flex flex-col gap-1 p-2">
{buildings.map((building) => (
<div
key={building.id}
className={cn(
"group/building flex items-center rounded-md text-sm transition-colors",
selectedBuildingId === building.id
? "bg-primary text-primary-foreground"
: "bg-accent/50 hover:bg-accent text-foreground"
)}
>
<button
className="flex-1 flex items-center gap-2 px-3 py-2 cursor-pointer min-w-0"
onClick={() => setSelection({ buildingId: building.id })}
>
<Building2 className="w-4 h-4 shrink-0" />
<span className="truncate">{building.name || "Building"}</span>
</button>
<Popover
open={buildingCameraOpen === building.id}
onOpenChange={(open) => setBuildingCameraOpen(open ? building.id : null)}
>
<PopoverTrigger asChild>
<button
className={cn(
"relative opacity-0 group-hover/building:opacity-100 w-6 h-6 mr-1 flex items-center justify-center rounded cursor-pointer shrink-0",
selectedBuildingId === building.id
? "hover:bg-primary-foreground/20"
: "hover:bg-accent-foreground/10"
)}
onClick={(e) => e.stopPropagation()}
title="Camera snapshot"
>
<Camera className="w-3.5 h-3.5" />
{building.camera && (
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-primary" />
)}
</button>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
className="w-auto p-1"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col gap-0.5">
{building.camera && (
<button
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
onClick={(e) => {
e.stopPropagation();
emitter.emit("camera-controls:view", { nodeId: building.id });
setBuildingCameraOpen(null);
}}
>
<Camera className="w-3.5 h-3.5" />
View snapshot
</button>
)}
<button
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
onClick={(e) => {
e.stopPropagation();
emitter.emit("camera-controls:capture", { nodeId: building.id });
setBuildingCameraOpen(null);
}}
>
<Camera className="w-3.5 h-3.5" />
{building.camera ? "Update snapshot" : "Take snapshot"}
</button>
{building.camera && (
<button
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-destructive hover:text-destructive-foreground text-left w-full"
onClick={(e) => {
e.stopPropagation();
updateNode(building.id, { camera: undefined });
setBuildingCameraOpen(null);
}}
>
<Trash2 className="w-3.5 h-3.5" />
Clear snapshot
</button>
)}
</div>
</PopoverContent>
</Popover>
</div>
))}
</div>
)}
</div>
);
}
// ============================================================================
// 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 (
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/50">
<Building2 className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="text-sm font-medium truncate">
{buildings[0]?.name || "Building"}
</span>
</div>
);
}
return (
<Popover>
<PopoverTrigger asChild>
<button className="flex items-center justify-between w-full px-3 py-2 border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="text-sm font-medium truncate">
{selectedBuilding?.name || "Select Building"}
</span>
</div>
<ChevronDown className="w-4 h-4 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-1">
{buildings.map((building) => (
<button
key={building.id}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm transition-colors cursor-pointer",
selectedBuildingId === building.id
? "bg-primary text-primary-foreground"
: "hover:bg-accent"
)}
onClick={() => {
setSelection({ buildingId: building.id });
// Also select first level if available
if (building.children.length > 0) {
setSelection({ levelId: building.children[0] as LevelNode["id"] });
}
}}
>
<Building2 className="w-4 h-4 shrink-0" />
<span className="truncate">{building.name || "Building"}</span>
</button>
))}
</PopoverContent>
</Popover>
);
}
function LevelsSection() {
const nodes = useScene((state) => state.nodes);
const createNode = useScene((state) => state.createNode);
const updateNode = useScene((state) => state.updateNode);
const deleteNode = useScene((state) => state.deleteNode);
const selectedBuildingId = useViewer((state) => state.selection.buildingId);
const selectedLevelId = useViewer((state) => state.selection.levelId);
const setSelection = useViewer((state) => state.setSelection);
const [referencesLevelId, setReferencesLevelId] = useState<string | null>(null);
const [cameraPopoverOpen, setCameraPopoverOpen] = useState<string | null>(null);
const building = selectedBuildingId
? (nodes[selectedBuildingId] as BuildingNode)
: null;
if (!building) return null;
const levels = building.children
.map((id) => nodes[id])
.filter((node): node is LevelNode => node?.type === "level");
const handleAddLevel = () => {
const newLevel = LevelNode.parse({
level: levels.length,
children: [],
parentId: building.id,
});
createNode(newLevel, building.id);
setSelection({ levelId: newLevel.id });
};
return (
<div className="border-b border-border/50">
{/* Header */}
<div className="flex items-center justify-between px-3 py-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Levels
</span>
<button
className="w-5 h-5 flex items-center justify-center rounded hover:bg-accent cursor-pointer"
onClick={handleAddLevel}
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
{/* Level buttons */}
<div className="flex flex-col gap-0.5 px-2 pb-2">
{levels.map((level) => (
<div
key={level.id}
className={cn(
"flex items-center group/level rounded transition-colors",
selectedLevelId === level.id
? "bg-primary text-primary-foreground"
: "hover:bg-accent/50 text-foreground"
)}
>
<button
className="flex-1 flex items-center gap-2 px-2 py-1.5 text-sm cursor-pointer min-w-0"
onClick={() => setSelection({ levelId: level.id })}
>
<Layers className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{level.name || `Level ${level.level}`}</span>
</button>
{/* Camera snapshot button */}
<Popover open={cameraPopoverOpen === level.id} onOpenChange={(open) => setCameraPopoverOpen(open ? level.id : null)}>
<Popover open={cameraPopoverOpen} onOpenChange={setCameraPopoverOpen}>
<PopoverTrigger asChild>
<button
className={cn(
"relative opacity-0 group-hover/level:opacity-100 w-6 h-6 mr-1 flex items-center justify-center rounded cursor-pointer shrink-0",
"relative opacity-0 group-hover/level:opacity-100 w-6 h-6 mr-1 flex items-center justify-center rounded-md cursor-pointer shrink-0 transition-colors",
selectedLevelId === level.id
? "hover:bg-primary-foreground/20"
: "hover:bg-accent"
? "hover:bg-black/5 dark:hover:bg-white/10"
: "hover:bg-accent text-muted-foreground hover:text-foreground"
)}
onClick={(e) => e.stopPropagation()}
title="Camera snapshot"
@@ -617,7 +394,7 @@ function LevelsSection() {
onClick={(e) => {
e.stopPropagation();
emitter.emit("camera-controls:view", { nodeId: level.id });
setCameraPopoverOpen(null);
setCameraPopoverOpen(false);
}}
>
<Camera className="w-3.5 h-3.5" />
@@ -629,7 +406,7 @@ function LevelsSection() {
onClick={(e) => {
e.stopPropagation();
emitter.emit("camera-controls:capture", { nodeId: level.id });
setCameraPopoverOpen(null);
setCameraPopoverOpen(false);
}}
>
<Camera className="w-3.5 h-3.5" />
@@ -641,7 +418,7 @@ function LevelsSection() {
onClick={(e) => {
e.stopPropagation();
updateNode(level.id, { camera: undefined });
setCameraPopoverOpen(null);
setCameraPopoverOpen(false);
}}
>
<Trash2 className="w-3.5 h-3.5" />
@@ -655,10 +432,10 @@ function LevelsSection() {
<PopoverTrigger asChild>
<button
className={cn(
"opacity-0 group-hover/level:opacity-100 w-6 h-6 mr-1 flex items-center justify-center rounded cursor-pointer shrink-0",
"opacity-0 group-hover/level:opacity-100 w-6 h-6 mr-1 flex items-center justify-center rounded-md cursor-pointer shrink-0 transition-colors",
selectedLevelId === level.id
? "hover:bg-primary-foreground/20"
: "hover:bg-accent"
? "hover:bg-black/5 dark:hover:bg-white/10"
: "hover:bg-accent text-muted-foreground hover:text-foreground"
)}
onClick={(e) => e.stopPropagation()}
>
@@ -684,6 +461,67 @@ function LevelsSection() {
</PopoverContent>
</Popover>
</div>
);
}
function LevelsSection() {
const nodes = useScene((state) => state.nodes);
const createNode = useScene((state) => state.createNode);
const updateNode = useScene((state) => state.updateNode);
const deleteNode = useScene((state) => state.deleteNode);
const selectedBuildingId = useViewer((state) => state.selection.buildingId);
const selectedLevelId = useViewer((state) => state.selection.levelId);
const setSelection = useViewer((state) => state.setSelection);
const [referencesLevelId, setReferencesLevelId] = useState<string | null>(null);
const building = selectedBuildingId
? (nodes[selectedBuildingId] as BuildingNode)
: null;
if (!building) return null;
const levels = building.children
.map((id) => nodes[id])
.filter((node): node is LevelNode => node?.type === "level");
const handleAddLevel = () => {
const newLevel = LevelNode.parse({
level: levels.length,
children: [],
parentId: building.id,
});
createNode(newLevel, building.id);
setSelection({ levelId: newLevel.id });
};
return (
<div className="flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border/50">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Levels
</span>
<button
className="w-5 h-5 flex items-center justify-center rounded hover:bg-accent cursor-pointer"
onClick={handleAddLevel}
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
{/* Level buttons */}
<div className="flex flex-col">
{levels.map((level) => (
<LevelItem
key={level.id}
level={level}
selectedLevelId={selectedLevelId}
setSelection={setSelection}
setReferencesLevelId={setReferencesLevelId}
deleteNode={deleteNode}
updateNode={updateNode}
/>
))}
{levels.length === 0 && (
<div className="text-xs text-muted-foreground px-2 py-1">
@@ -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 (
<div className="flex items-center gap-1 px-3 py-2 border-b border-border/50">
<div className="flex items-center p-1 bg-accent/20 gap-1 border-b border-border/50">
<button
className={cn(
"flex-1 px-3 py-1.5 rounded text-xs font-medium transition-colors cursor-pointer",
structureLayer === "elements"
? "bg-primary text-primary-foreground"
: "bg-accent/50 hover:bg-accent text-muted-foreground"
"flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
phase === "structure" && structureLayer === "elements"
? "bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50"
)}
onClick={() => setStructureLayer("elements")}
onClick={() => {
setPhase("structure");
setStructureLayer("elements");
}}
>
Elements
<img
src="/icons/room.png"
alt="Structure"
className={cn("w-6 h-6 mb-1", !(phase === "structure" && structureLayer === "elements") && "opacity-50 grayscale")}
/>
Structure
</button>
<button
className={cn(
"flex-1 px-3 py-1.5 rounded text-xs font-medium transition-colors cursor-pointer",
structureLayer === "zones"
? "bg-primary text-primary-foreground"
: "bg-accent/50 hover:bg-accent text-muted-foreground"
"flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
phase === "furnish"
? "bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50"
)}
onClick={() => setStructureLayer("zones")}
onClick={() => {
setPhase("furnish");
}}
>
<img
src="/icons/couch.png"
alt="Furnish"
className={cn("w-6 h-6 mb-1", phase !== "furnish" && "opacity-50 grayscale")}
/>
Furnish
</button>
<button
className={cn(
"flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
phase === "structure" && structureLayer === "zones"
? "bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50"
)}
onClick={() => {
setPhase("structure");
setStructureLayer("zones");
}}
>
<img
src="/icons/kitchen.png"
alt="Zones"
className={cn("w-6 h-6 mb-1", !(phase === "structure" && structureLayer === "zones") && "opacity-50 grayscale")}
/>
Zones
</button>
</div>
@@ -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<HTMLDivElement>(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,20 +661,15 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
};
return (
<RenamePopover
node={zone}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<div
ref={itemRef}
className={cn(
"flex items-center h-7 cursor-pointer group/row text-sm px-3 select-none",
"flex items-center h-8 cursor-pointer group/row text-sm px-3 select-none border-b border-border/50 transition-all duration-200",
isSelected
? "text-primary-foreground bg-primary/80 hover:bg-primary/90"
? "bg-accent/50 text-foreground"
: isHovered
? "bg-accent/70 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
? "bg-accent/30 text-foreground"
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
@@ -802,7 +679,10 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
<Popover>
<PopoverTrigger asChild>
<button
className="mr-2 size-3 shrink-0 rounded-sm border border-border/50 transition-transform hover:scale-110 cursor-pointer"
className={cn(
"mr-2 size-3 shrink-0 rounded-sm border border-border/50 transition-all hover:scale-110 cursor-pointer",
!isSelected && "opacity-60 grayscale"
)}
onClick={(e) => e.stopPropagation()}
style={{ backgroundColor: zone.color }}
/>
@@ -827,12 +707,18 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
</div>
</PopoverContent>
</Popover>
<span className="truncate flex-1">{zone.name || defaultName}</span>
<InlineRenameInput
node={zone}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={defaultName}
/>
{/* Camera snapshot button */}
<Popover open={cameraPopoverOpen} onOpenChange={setCameraPopoverOpen}>
<PopoverTrigger asChild>
<button
className="relative opacity-0 group-hover/row:opacity-100 w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
className="relative opacity-0 group-hover/row:opacity-100 w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => e.stopPropagation()}
title="Camera snapshot"
>
@@ -890,13 +776,12 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
</PopoverContent>
</Popover>
<button
className="opacity-0 group-hover/row:opacity-100 w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
className="opacity-0 group-hover/row:opacity-100 w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
onClick={handleDelete}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</RenamePopover>
);
}
@@ -947,7 +832,7 @@ function ContentSection() {
}
return (
<div className="py-1">
<div className="flex flex-col">
{levelZones.map((zone) => (
<ZoneItem key={zone.id} zone={zone} />
))}
@@ -994,7 +879,7 @@ function ContentSection() {
}
return (
<div className="py-1">
<div className="flex flex-col">
{elementChildren.map((childId) => (
<TreeNode key={childId} nodeId={childId} depth={0} />
))}
@@ -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<HTMLDivElement>(null);
useEffect(() => {
if (isBuildingActive && itemRef.current) {
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [isBuildingActive]);
return (
<div className="flex flex-col h-full">
<BuildingSelector />
<div className="flex flex-col">
<div
ref={itemRef}
className={cn(
"group/building flex items-center h-10 border-b border-border/50 pr-2 transition-all duration-200",
isBuildingActive
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
)}
>
<button
className="flex-1 flex items-center gap-2 pl-3 py-2 h-full cursor-pointer min-w-0"
onClick={() => {
setSelection({ buildingId: building.id });
if (phase === "site") {
setPhase("structure");
}
}}
>
<img
src="/icons/building.png"
className={cn("w-5 h-5 object-contain transition-all", !isBuildingActive && "opacity-60 grayscale")}
alt="Building"
/>
<span className="truncate font-medium text-sm">{building.name || "Building"}</span>
</button>
<Popover
open={buildingCameraOpen === building.id}
onOpenChange={(open) => setBuildingCameraOpen(open ? building.id : null)}
>
<PopoverTrigger asChild>
<button
className={cn(
"relative opacity-0 group-hover/building:opacity-100 w-7 h-7 mr-1.5 flex items-center justify-center rounded-md cursor-pointer shrink-0 transition-colors",
isBuildingActive
? "hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground"
: "hover:bg-accent text-muted-foreground hover:text-foreground"
)}
onClick={(e) => e.stopPropagation()}
title="Camera snapshot"
>
<Camera className="w-4 h-4" />
{building.camera && (
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-primary" />
)}
</button>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
className="w-auto p-1"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col gap-0.5">
{building.camera && (
<button
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
onClick={(e) => {
e.stopPropagation();
emitter.emit("camera-controls:view", { nodeId: building.id });
setBuildingCameraOpen(null);
}}
>
<Camera className="w-3.5 h-3.5" />
View snapshot
</button>
)}
<button
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
onClick={(e) => {
e.stopPropagation();
emitter.emit("camera-controls:capture", { nodeId: building.id });
setBuildingCameraOpen(null);
}}
>
<Camera className="w-3.5 h-3.5" />
{building.camera ? "Update snapshot" : "Take snapshot"}
</button>
{building.camera && (
<button
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-destructive hover:text-destructive-foreground text-left w-full"
onClick={(e) => {
e.stopPropagation();
updateNode(building.id, { camera: undefined });
setBuildingCameraOpen(null);
}}
>
<Trash2 className="w-3.5 h-3.5" />
Clear snapshot
</button>
)}
</div>
</PopoverContent>
</Popover>
</div>
{/* Tools and content for the active building */}
{isBuildingActive && (
<div className="flex flex-col animate-in fade-in slide-in-from-top-2 duration-200">
<LevelsSection />
{/* Only show layer toggle in structure phase, furnish is always elements */}
{phase === "structure" && <LayerToggle />}
<div className="flex-1 overflow-auto">
<LayerToggle />
<ContentSection />
</div>
)}
</div>
);
}
// ============================================================================
// 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 <SitePhaseView />;
}
const [siteCameraOpen, setSiteCameraOpen] = useState(false);
const [buildingCameraOpen, setBuildingCameraOpen] = useState<string | null>(null);
return <StructurePhaseView />;
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 (
<div className="flex flex-col h-full">
{/* Site Header */}
{siteNode && (
<div
className={cn(
"flex items-center justify-between px-3 py-3 border-b border-border/50 cursor-pointer transition-colors",
phase === "site" ? "bg-accent/50 text-foreground" : "hover:bg-accent/30 text-muted-foreground hover:text-foreground"
)}
onClick={() => setPhase("site")}
>
<div className="flex items-center gap-2">
<img
src="/icons/site.png"
className={cn("w-5 h-5 object-contain transition-all", phase !== "site" && "opacity-60 grayscale")}
alt="Site"
/>
<span className="text-sm font-medium">{siteNode.name || "Site"}</span>
</div>
<CameraPopover
nodeId={siteNode.id as AnyNodeId}
hasCamera={!!siteNode.camera}
open={siteCameraOpen}
onOpenChange={setSiteCameraOpen}
buttonClassName={cn("transition-colors", phase === "site" ? "hover:bg-black/5 dark:hover:bg-white/10" : "hover:bg-accent")}
/>
</div>
)}
<div className="flex-1 overflow-auto flex flex-col">
{/* When phase is site, show property line immediately under site header */}
{phase === "site" && <PropertyLineSection />}
{/* Buildings List */}
{buildings.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground">
No buildings yet
</div>
) : (
<div className="flex flex-col">
{buildings.map((building) => {
const isBuildingActive = (phase === "structure" || phase === "furnish") && selectedBuildingId === building.id;
return (
<BuildingItem
key={building.id}
building={building}
isBuildingActive={isBuildingActive}
buildingCameraOpen={buildingCameraOpen}
setBuildingCameraOpen={setBuildingCameraOpen}
/>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -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<HTMLInputElement>(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 (
<div className="flex items-center gap-1 group/rename min-w-0">
<span
className={cn("truncate border-b border-transparent", className)}
>
{node.name || defaultName}
</span>
{onStartEditing && (
<button
className="opacity-0 group-hover/rename:opacity-100 transition-opacity text-muted-foreground hover:text-foreground shrink-0"
onClick={(e) => {
e.stopPropagation();
onStartEditing();
}}
>
<Pencil className="w-3 h-3" />
</button>
)}
</div>
);
}
return (
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => 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()}
/>
);
}
@@ -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,15 +50,17 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
const hasChildren = node.children && node.children.length > 0;
return (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper
icon={<Image src={iconSrc} alt="" width={14} height={14} className="object-contain" />}
label={node.name || defaultName}
label={
<InlineRenameInput
node={node}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={defaultName}
/>
}
depth={depth}
hasChildren={hasChildren}
expanded={expanded}
@@ -76,6 +78,5 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</TreeNodeWrapper>
</RenamePopover>
);
}
@@ -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,21 +23,23 @@ export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) {
};
const handleDoubleClick = () => {
setRenameOpen(true);
setIsEditing(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 || defaultName}
label={
<InlineRenameInput
node={node}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={defaultName}
/>
}
depth={depth}
hasChildren={node.children.length > 0}
expanded={expanded}
@@ -52,6 +54,5 @@ export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) {
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</TreeNodeWrapper>
</RenamePopover>
);
}
@@ -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<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>
);
}
@@ -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,15 +40,17 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
const defaultName = `Roof (${sizeLabel})`;
return (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper
icon={<Image src="/icons/roof.png" alt="" width={14} height={14} className="object-contain" />}
label={node.name || defaultName}
label={
<InlineRenameInput
node={node}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={defaultName}
/>
}
depth={depth}
hasChildren={false}
expanded={false}
@@ -62,6 +64,5 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
isVisible={node.visible !== false}
actions={<TreeNodeActions node={node} />}
/>
</RenamePopover>
);
}
@@ -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,15 +39,17 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
const defaultName = `Slab (${area}m²)`;
return (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper
icon={<Image src="/icons/floor.png" alt="" width={14} height={14} className="object-contain" />}
label={node.name || defaultName}
label={
<InlineRenameInput
node={node}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={defaultName}
/>
}
depth={depth}
hasChildren={false}
expanded={false}
@@ -61,7 +63,6 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
isVisible={node.visible !== false}
actions={<TreeNodeActions node={node} />}
/>
</RenamePopover>
);
}
@@ -42,7 +42,7 @@ export function TreeNodeActions({ node }: TreeNodeActionsProps) {
return (
<div className="flex items-center gap-0.5">
<button
className="w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
className="w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
onClick={toggleVisibility}
title={isVisible ? "Hide" : "Show"}
>
@@ -56,7 +56,7 @@ export function TreeNodeActions({ node }: TreeNodeActionsProps) {
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className="relative w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
className="relative w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => e.stopPropagation()}
title="Camera snapshot"
>
@@ -1,6 +1,6 @@
import { AnyNodeId, useScene } from "@pascal-app/core";
import { ChevronDown, ChevronRight } from "lucide-react";
import { forwardRef } from "react";
import { forwardRef, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { BuildingTreeNode } from "./building-tree-node";
import { CeilingTreeNode } from "./ceiling-tree-node";
@@ -51,7 +51,7 @@ export function TreeNode({ nodeId, depth = 0 }: TreeNodeProps) {
interface TreeNodeWrapperProps {
icon: React.ReactNode;
label: string;
label: React.ReactNode;
depth: number;
hasChildren: boolean;
expanded: boolean;
@@ -88,19 +88,28 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
},
ref
) {
const rowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isSelected && rowRef.current) {
rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [isSelected]);
return (
<div ref={ref}>
<div
ref={rowRef}
className={cn(
"flex items-center h-7 cursor-pointer group/row text-sm select-none",
"flex items-center h-8 cursor-pointer group/row text-sm select-none border-b border-border/50 transition-all duration-200",
isSelected
? "text-primary-foreground bg-primary/80 hover:bg-primary/90"
? "bg-accent/50 text-foreground"
: isHovered
? "bg-accent/70 text-foreground"
: "text-muted-foreground hover:bg-accent/50",
? "bg-accent/30 text-foreground"
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground",
!isVisible && "opacity-50"
)}
style={{ paddingLeft: depth * 12 + 4 }}
style={{ paddingLeft: depth * 12 + 12, paddingRight: 12 }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
@@ -124,10 +133,15 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
onClick={onClick}
onDoubleClick={onDoubleClick}
>
<span className="w-4 h-4 flex items-center justify-center shrink-0">
<span className={cn(
"w-4 h-4 flex items-center justify-center shrink-0 transition-all duration-200",
!isSelected && "opacity-60 grayscale"
)}>
{icon}
</span>
<span className="truncate">{label}</span>
<div className="flex-1 min-w-0 truncate">
{label}
</div>
</div>
{actions && (
<div className="opacity-0 group-hover/row:opacity-100 pr-1">
@@ -2,7 +2,7 @@ import { WallNode } 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";
@@ -13,7 +13,7 @@ interface WallTreeNodeProps {
export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
const [expanded, setExpanded] = useState(false);
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);
@@ -24,7 +24,7 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
};
const handleDoubleClick = () => {
setRenameOpen(true);
setIsEditing(true);
};
const handleMouseEnter = () => {
@@ -43,15 +43,17 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
const defaultName = `Wall (${wallLength}m/${node.height || 2.5}m)`;
return (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper
icon={<Image src="/icons/wall.png" alt="" width={14} height={14} className="object-contain" />}
label={node.name || defaultName}
label={
<InlineRenameInput
node={node}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={defaultName}
/>
}
depth={depth}
hasChildren={node.children.length > 0}
expanded={expanded}
@@ -69,6 +71,5 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
))}
</TreeNodeWrapper>
</RenamePopover>
);
}
@@ -4,7 +4,7 @@ import { WindowNode } 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"
@@ -14,7 +14,7 @@ interface WindowTreeNodeProps {
}
export function WindowTreeNode({ node, depth }: WindowTreeNodeProps) {
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,21 +23,23 @@ export function WindowTreeNode({ node, depth }: WindowTreeNodeProps) {
const defaultName = `Window (${node.width}×${node.height}m)`
return (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper
icon={<Image src="/icons/window.png" alt="" width={14} height={14} className="object-contain" />}
label={node.name || defaultName}
label={
<InlineRenameInput
node={node}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={defaultName}
/>
}
depth={depth}
hasChildren={false}
expanded={false}
onToggle={() => {}}
onClick={() => setSelection({ selectedIds: [node.id] })}
onDoubleClick={() => setRenameOpen(true)}
onDoubleClick={() => setIsEditing(true)}
onMouseEnter={() => setHoveredId(node.id)}
onMouseLeave={() => setHoveredId(null)}
isSelected={isSelected}
@@ -45,6 +47,5 @@ export function WindowTreeNode({ node, depth }: WindowTreeNodeProps) {
isVisible={node.visible !== false}
actions={<TreeNodeActions node={node} />}
/>
</RenamePopover>
)
}
@@ -1,7 +1,7 @@
import { ZoneNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
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";
@@ -11,7 +11,7 @@ interface ZoneTreeNodeProps {
}
export function ZoneTreeNode({ node, depth }: ZoneTreeNodeProps) {
const [renameOpen, setRenameOpen] = useState(false);
const [isEditing, setIsEditing] = 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);
@@ -22,7 +22,7 @@ export function ZoneTreeNode({ node, depth }: ZoneTreeNodeProps) {
};
const handleDoubleClick = () => {
setRenameOpen(true);
setIsEditing(true);
};
const handleMouseEnter = () => {
@@ -38,12 +38,6 @@ export function ZoneTreeNode({ node, depth }: ZoneTreeNodeProps) {
const defaultName = `Zone (${area}m²)`;
return (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper
icon={
<div
@@ -51,7 +45,15 @@ export function ZoneTreeNode({ node, depth }: ZoneTreeNodeProps) {
style={{ backgroundColor: node.color }}
/>
}
label={node.name || defaultName}
label={
<InlineRenameInput
node={node}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={defaultName}
/>
}
depth={depth}
hasChildren={false}
expanded={false}
@@ -64,7 +66,6 @@ export function ZoneTreeNode({ node, depth }: ZoneTreeNodeProps) {
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
/>
</RenamePopover>
);
}
@@ -50,10 +50,10 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
return (
<div
className={cn(
"flex items-center h-7 cursor-pointer group/row text-sm px-3",
"flex items-center h-8 cursor-pointer group/row text-sm px-2 mx-1 mb-0.5 select-none rounded-lg border transition-all duration-200",
isSelected
? "text-primary-foreground bg-primary/80 hover:bg-primary/90"
: "text-muted-foreground hover:bg-accent/50"
? "bg-white dark:bg-accent/50 border-neutral-200/60 dark:border-border/50 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] ring-1 ring-white/50 dark:ring-white/10 ring-inset text-foreground"
: "border-transparent text-muted-foreground hover:bg-white/40 dark:hover:bg-accent/30 hover:border-neutral-200/50 dark:hover:border-border/40 hover:text-foreground"
)}
onClick={handleClick}
>
@@ -91,7 +91,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
<Popover open={cameraPopoverOpen} onOpenChange={setCameraPopoverOpen}>
<PopoverTrigger asChild>
<button
className="relative opacity-0 group-hover/row:opacity-100 w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
className="relative opacity-0 group-hover/row:opacity-100 w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => e.stopPropagation()}
title="Camera snapshot"
>
@@ -149,7 +149,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
</PopoverContent>
</Popover>
<button
className="opacity-0 group-hover/row:opacity-100 w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
className="opacity-0 group-hover/row:opacity-100 w-6 h-6 flex items-center justify-center rounded-md cursor-pointer hover:bg-black/5 dark:hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
onClick={handleDelete}
>
<Trash2 className="w-3 h-3" />
+38
View File
@@ -0,0 +1,38 @@
import { useState } from "react";
import NumberFlow from "@number-flow/react";
import { Slider } from "@/components/ui/slider";
export function SliderDemo() {
const [value, setValue] = useState<number[]>([28.1]);
return (
<div className="flex min-h-screen items-center justify-center bg-[#ededed] px-8">
<section className="w-full max-w-lg">
<div className="mb-2 flex items-end justify-between">
<h2 className="text-xl font-semibold tracking-tight text-black">
Temperature
</h2>
<NumberFlow
value={value[0] ?? 50}
className="text-xl font-medium text-black/45"
format={{ minimumFractionDigits: 1, maximumFractionDigits: 1 }}
suffix="%"
/>
</div>
<Slider
variant="temperature"
value={value}
onValueChange={setValue}
min={0}
max={100}
step={0.1}
aria-label="Temperature"
/>
</section>
</div>
);
}
export default SliderDemo;
+75 -19
View File
@@ -1,28 +1,84 @@
'use client'
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils'
const sliderVariants = cva(
"relative flex w-full touch-none select-none overflow-hidden items-center",
{
variants: {
variant: {
default: "",
temperature: `
h-16
[&_[data-slot=slider-track]]:h-14
[&_[data-slot=slider-track]]:rounded-xl
[&_[data-slot=slider-track]]:border
[&_[data-slot=slider-track]]:border-neutral-300
[&_[data-slot=slider-track]]:bg-white/50
[&_[data-slot=slider-track]]:shadow-[0_1px_2px_0px_rgba(0,0,0,0.1)]
[&_[data-slot=slider-track]]:ring-1
[&_[data-slot=slider-track]]:ring-white
[&_[data-slot=slider-track]]:ring-inset
[&_[data-slot=slider-range]]:inset-y-0.5
[&_[data-slot=slider-range]]:h-auto
[&_[data-slot=slider-range]]:ml-0.5
[&_[data-slot=slider-range]]:mr-0.5
[&_[data-slot=slider-range]]:overflow-hidden
[&_[data-slot=slider-range]]:rounded-lg
[&_[data-slot=slider-range]]:border
[&_[data-slot=slider-range]]:border-neutral-300
[&_[data-slot=slider-range]]:bg-white
[&_[data-slot=slider-range]]:shadow-xs
[&_[data-slot=slider-thumb]]:h-7
[&_[data-slot=slider-thumb]]:w-[3px]
[&_[data-slot=slider-thumb]]:rounded-xl
[&_[data-slot=slider-thumb]]:border-0
[&_[data-slot=slider-thumb]]:bg-neutral-100
[&_[data-slot=slider-thumb]]:shadow-none
[&_[data-slot=slider-thumb]]:cursor-ew-resize
[&_[data-slot=slider-thumb]]:[transform:translateX(-8px)]
[&_[data-slot=slider-thumb]]:ring-0
[&_[data-slot=slider-thumb]]:hover:ring-0
[&_[data-slot=slider-thumb]]:focus-visible:ring-0
`,
},
},
defaultVariants: {
variant: "default",
},
}
);
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
type SliderProps = React.ComponentProps<typeof SliderPrimitive.Root> &
VariantProps<typeof sliderVariants>;
function Slider({ variant, className, ...props }: SliderProps) {
return (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
className,
)}
data-slot="slider"
className={cn(sliderVariants({ variant }), className)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
<SliderPrimitive.Track
data-slot="slider-track"
className="bg-muted relative h-3 w-full grow overflow-hidden rounded-full"
>
<SliderPrimitive.Range
data-slot="slider-range"
className="bg-primary absolute h-full"
/>
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb
data-slot="slider-thumb"
className={cn(
"border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm",
"transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
)}
/>
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
);
}
export { Slider }
export { Slider };
+54
View File
@@ -0,0 +1,54 @@
import { type AnyNodeId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useMemo } from 'react'
import useEditor, { type StructureTool } from '@/store/use-editor'
export function useContextualTools() {
const selection = useViewer((s) => s.selection)
const nodes = useScene((s) => s.nodes)
const phase = useEditor((s) => s.phase)
const structureLayer = useEditor((s) => s.structureLayer)
return useMemo(() => {
// If we are in the zones layer, only zone tool is relevant
if (structureLayer === 'zones') {
return ['zone'] as StructureTool[]
}
// Default tools when nothing is selected
const defaultTools: StructureTool[] = ['wall', 'slab', 'ceiling', 'roof', 'door', 'window']
if (selection.selectedIds.length === 0) {
return defaultTools
}
// Get types of selected nodes
const selectedTypes = new Set(
selection.selectedIds
.map((id) => nodes[id as AnyNodeId]?.type)
.filter(Boolean)
)
// If a wall is selected, prioritize wall-hosted elements
if (selectedTypes.has('wall')) {
return ['window', 'door', 'wall'] as StructureTool[]
}
// If a slab is selected, prioritize slab editing
if (selectedTypes.has('slab')) {
return ['slab', 'wall'] as StructureTool[]
}
// If a ceiling is selected, prioritize ceiling editing
if (selectedTypes.has('ceiling')) {
return ['ceiling'] as StructureTool[]
}
// If a roof is selected, prioritize roof editing
if (selectedTypes.has('roof')) {
return ['roof'] as StructureTool[]
}
return defaultTools
}, [selection.selectedIds, nodes, structureLayer])
}
+2 -1
View File
@@ -4,13 +4,14 @@
"type": "module",
"private": true,
"scripts": {
"dev": "test ! -f .env.local || (echo 'Use root .env only; .env.local is forbidden for this app.' && exit 1); set -a && . ../../.env 2>/dev/null; set +a; next dev",
"dev": "set -a && . ../../.env 2>/dev/null; set +a; next dev",
"build": "next build",
"start": "next start",
"lint": "biome lint",
"check-types": "next typegen && tsc --noEmit"
},
"dependencies": {
"@number-flow/react": "^0.5.14",
"@pascal-app/auth": "*",
"@pascal-app/core": "*",
"@pascal-app/db": "*",
+13 -1
View File
@@ -7,6 +7,7 @@
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"portless": "^0.4.2",
"three": "^0.183.0",
"three-bvh-csg": "^0.0.17",
"three-mesh-bvh": "^0.9.8",
@@ -24,6 +25,7 @@
"name": "web",
"version": "0.1.0",
"dependencies": {
"@number-flow/react": "^0.5.14",
"@pascal-app/auth": "*",
"@pascal-app/core": "*",
"@pascal-app/db": "*",
@@ -418,6 +420,8 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@number-flow/react": ["@number-flow/react@0.5.14", "", { "dependencies": { "esm-env": "^1.1.4", "number-flow": "0.5.12" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-FGUqjh/P5/ukr0U0ySwb987M0SbRkrnZq70f0wQFncDbXa3SIib4L+FTr5ngvWwGAW8S6b391eTXcfhErZsw4w=="],
"@pascal-app/auth": ["@pascal-app/auth@workspace:packages/auth"],
"@pascal-app/core": ["@pascal-app/core@workspace:packages/core"],
@@ -766,7 +770,7 @@
"caniuse-lite": ["caniuse-lite@1.0.30001764", "", {}, "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"charenc": ["charenc@0.0.2", "", {}, "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA=="],
@@ -908,6 +912,8 @@
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
@@ -1236,6 +1242,8 @@
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
"number-flow": ["number-flow@0.5.12", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-CIs21h2JkfYG4rfgERaUNAk0Cz+Ef14fNJfSCbGGhgRgconQc9b7rcCQfi9SZ36kNjVXmsl2BrzDbjGtEgumAA=="],
"nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -1300,6 +1308,8 @@
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"portless": ["portless@0.4.2", "", { "dependencies": { "chalk": "^5.3.0" }, "os": [ "linux", "darwin", ], "bin": { "portless": "dist/cli.js" } }, "sha512-/G3jIeD1XokoO9KY/lUGTV9irKz3tgx8yqHkz+hvj/86QeR219GvYFB+QEfpvjRlvvLVJa5vE7BkRfBZBe/lQg=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
@@ -1650,6 +1660,8 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"gel/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
+1
View File
@@ -29,6 +29,7 @@
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"portless": "^0.4.2",
"three": "^0.183.0",
"three-bvh-csg": "^0.0.17",
"three-mesh-bvh": "^0.9.8",