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 *)); @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 { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-barlow), sans-serif;
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono), monospace;
--color-sidebar-ring: var(--sidebar-ring); --font-barlow: var(--font-barlow), sans-serif;
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
+10 -2
View File
@@ -1,5 +1,6 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import localFont from 'next/font/local' import localFont from 'next/font/local'
import { Barlow } from 'next/font/google'
import { Analytics } from '@vercel/analytics/react' import { Analytics } from '@vercel/analytics/react'
import { SpeedInsights } from '@vercel/speed-insights/next' import { SpeedInsights } from '@vercel/speed-insights/next'
import { VercelToolbar } from '@vercel/toolbar/next' import { VercelToolbar } from '@vercel/toolbar/next'
@@ -16,6 +17,13 @@ const geistMono = localFont({
variable: '--font-geist-mono', variable: '--font-geist-mono',
}) })
const barlow = Barlow({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
variable: '--font-barlow',
display: 'swap',
})
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(siteConfig.url), metadataBase: new URL(siteConfig.url),
title: { title: {
@@ -69,8 +77,8 @@ export default function RootLayout({
const shouldShowToolbar = process.env.NODE_ENV === 'development' const shouldShowToolbar = process.env.NODE_ENV === 'development'
return ( return (
<html lang="en"> <html lang="en" className={`${geistSans.variable} ${geistMono.variable} ${barlow.variable}`}>
<body className={`${geistSans.variable} ${geistMono.variable}`}> <body className="font-sans">
<UsernameGate>{children}</UsernameGate> <UsernameGate>{children}</UsernameGate>
<Analytics /> <Analytics />
<SpeedInsights /> <SpeedInsights />
@@ -123,32 +123,16 @@ export const SelectionManager = () => {
const strategy = SELECTION_STRATEGIES[phase]; const strategy = SELECTION_STRATEGIES[phase];
if (!strategy) return; 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) => { const onClick = (event: NodeEvent) => {
if (!strategy.isValid(event.node)) return; if (!strategy.isValid(event.node)) return;
event.stopPropagation(); event.stopPropagation();
const isShift = event.nativeEvent?.shiftKey; 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 // Bind listeners for all potential types this strategy might care about
strategy.types.forEach((type) => { strategy.types.forEach((type) => {
emitter.on(`${type}:enter`, onEnter);
emitter.on(`${type}:leave`, onLeave);
emitter.on(`${type}:click`, onClick); emitter.on(`${type}:click`, onClick);
}); });
@@ -157,14 +141,118 @@ export const SelectionManager = () => {
return () => { return () => {
strategy.types.forEach((type) => { strategy.types.forEach((type) => {
emitter.off(`${type}:enter`, onEnter);
emitter.off(`${type}:leave`, onLeave);
emitter.off(`${type}:click`, onClick); emitter.off(`${type}:click`, onClick);
}); });
emitter.off("grid:click", onGridClick); emitter.off("grid:click", onGridClick);
}; };
}, [phase, mode, movingNode]); }, [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 />; return <EditorOutlinerSync />;
}; };
@@ -1,7 +1,7 @@
import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' 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 { mix, positionLocal } from 'three/tsl'
import { sfxEmitter } from '@/lib/sfx-bus' import { sfxEmitter } from '@/lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
@@ -66,10 +66,12 @@ const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array<[number, n
} }
export const CeilingTool: React.FC = () => { export const CeilingTool: React.FC = () => {
const cursorRef = useRef<Mesh>(null) const cursorRef = useRef<Group>(null)
const gridCursorRef = useRef<Mesh>(null) const gridCursorRef = useRef<Group>(null)
const mainLineRef = useRef<Line>(null!) const mainLineRef = useRef<Line>(null!)
const closingLineRef = useRef<Line>(null!) const closingLineRef = useRef<Line>(null!)
const groundMainLineRef = useRef<Line>(null!)
const groundClosingLineRef = useRef<Line>(null!)
const verticalLineRef = useRef<Line>(null!) const verticalLineRef = useRef<Line>(null!)
const currentLevelId = useViewer((state) => state.selection.levelId) const currentLevelId = useViewer((state) => state.selection.levelId)
const setSelection = useViewer((state) => state.setSelection) 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)) const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z))
linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1])) 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 // Update main line
if (linePoints.length >= 2) { if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose() mainLineRef.current.geometry.dispose()
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints) mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
mainLineRef.current.visible = true mainLineRef.current.visible = true
groundMainLineRef.current.geometry.dispose()
groundMainLineRef.current.geometry = new BufferGeometry().setFromPoints(groundLinePoints)
groundMainLineRef.current.visible = true
} else { } else {
mainLineRef.current.visible = false mainLineRef.current.visible = false
groundMainLineRef.current.visible = false
} }
// Update closing line (from cursor back to first point) // 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.dispose()
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints) closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
closingLineRef.current.visible = true 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 { } else {
closingLineRef.current.visible = false closingLineRef.current.visible = false
groundClosingLineRef.current.visible = false
} }
}, [points, snappedCursorPosition, levelY]) }, [points, snappedCursorPosition, levelY])
@@ -277,16 +297,16 @@ export const CeilingTool: React.FC = () => {
{/* Grid-level cursor indicator */} {/* Grid-level cursor indicator */}
<mesh ref={gridCursorRef} rotation={[-Math.PI / 2, 0, 0]} renderOrder={2}> <mesh ref={gridCursorRef} rotation={[-Math.PI / 2, 0, 0]} renderOrder={2}>
<ringGeometry args={[0.15, 0.2, 32]} /> <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> </mesh>
{/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */} {/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */}
{/* @ts-ignore */} {/* @ts-ignore */}
<line ref={verticalLineRef} geometry={verticalGeo} renderOrder={1}> <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> </line>
{/* Preview fill */} {/* Preview fill (Top) */}
{previewShape && ( {previewShape && (
<mesh <mesh
frustumCulled={false} frustumCulled={false}
@@ -295,9 +315,27 @@ export const CeilingTool: React.FC = () => {
> >
<shapeGeometry args={[previewShape]} /> <shapeGeometry args={[previewShape]} />
<meshBasicMaterial <meshBasicMaterial
color="#d4d4d4" color="#818cf8"
depthTest={false} 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} side={DoubleSide}
transparent transparent
/> />
@@ -308,7 +346,7 @@ export const CeilingTool: React.FC = () => {
{/* @ts-ignore */} {/* @ts-ignore */}
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}> <line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry /> <bufferGeometry />
<lineBasicNodeMaterial color="#a3a3a3" linewidth={3} depthTest={false} depthWrite={false} /> <lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} />
</line> </line>
{/* Closing line */} {/* Closing line */}
@@ -316,7 +354,7 @@ export const CeilingTool: React.FC = () => {
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}> <line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry /> <bufferGeometry />
<lineBasicNodeMaterial <lineBasicNodeMaterial
color="#a3a3a3" color="#818cf8"
linewidth={2} linewidth={2}
depthTest={false} depthTest={false}
depthWrite={false} depthWrite={false}
@@ -325,12 +363,34 @@ export const CeilingTool: React.FC = () => {
/> />
</line> </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 */} {/* Point markers */}
{points.map(([x, z], index) => ( {points.map(([x, z], index) => (
<CursorSphere <CursorSphere
key={index} key={index}
position={[x, levelY + CEILING_HEIGHT + 0.01, z]} position={[x, levelY + CEILING_HEIGHT + 0.01, z]}
color={index === 0 ? '#22c55e' : undefined} color="#818cf8"
showTooltip={false}
/> />
))} ))}
</group> </group>
+36 -34
View File
@@ -8,13 +8,15 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' 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 { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor' import useEditor from '@/store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
// Default roof dimensions // Default roof dimensions
const DEFAULT_HEIGHT = 1.5 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 * Creates a roof with the given corners
@@ -61,6 +63,7 @@ type PreviewState = {
} }
export const RoofTool: React.FC = () => { export const RoofTool: React.FC = () => {
const cursorRef = useRef<Group>(null)
const outlineRef = useRef<Line>(null!) const outlineRef = useRef<Line>(null!)
const currentLevelId = useViewer((state) => state.selection.levelId) const currentLevelId = useViewer((state) => state.selection.levelId)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
@@ -85,20 +88,24 @@ export const RoofTool: React.FC = () => {
corner1: [number, number, number], corner1: [number, number, number],
corner2: [number, number, number], corner2: [number, number, number],
) => { ) => {
const y = corner1[1] + PREVIEW_LINE_HEIGHT const gridY = corner1[1] + GRID_OFFSET
const points = [
new Vector3(corner1[0], y, corner1[2]), const groundPoints = [
new Vector3(corner2[0], y, corner1[2]), new Vector3(corner1[0], gridY, corner1[2]),
new Vector3(corner2[0], y, corner2[2]), new Vector3(corner2[0], gridY, corner1[2]),
new Vector3(corner1[0], y, corner2[2]), new Vector3(corner2[0], gridY, corner2[2]),
new Vector3(corner1[0], y, corner1[2]), // Close the loop new Vector3(corner1[0], gridY, corner2[2]),
new Vector3(corner1[0], gridY, corner1[2]), // Close the loop
] ]
outlineRef.current.geometry.dispose() outlineRef.current.geometry.dispose()
outlineRef.current.geometry = new BufferGeometry().setFromPoints(points) outlineRef.current.geometry = new BufferGeometry().setFromPoints(groundPoints)
outlineRef.current.visible = true outlineRef.current.visible = true
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return
// Snap to 0.5 grid // Snap to 0.5 grid
const gridX = Math.round(event.position[0] * 2) / 2 const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 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] 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) // Play snap sound when grid position changes (only when placing)
if ( if (
corner1Ref.current && corner1Ref.current &&
@@ -153,10 +165,6 @@ export const RoofTool: React.FC = () => {
// Reset state // Reset state
corner1Ref.current = null corner1Ref.current = null
outlineRef.current.visible = false 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 ( return (
<group> <group>
{/* Outline showing rectangle being drawn */} {/* Cursor at ground height */}
<CursorSphere ref={cursorRef} />
{/* Outline showing rectangle being drawn (Ground) */}
{/* @ts-ignore */} {/* @ts-ignore */}
<line ref={outlineRef} frustumCulled={false} renderOrder={1} visible={false}> <line ref={outlineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry /> <bufferGeometry />
<lineBasicNodeMaterial color="#8b4513" linewidth={2} depthTest={false} depthWrite={false} /> <lineBasicNodeMaterial color="#818cf8" linewidth={2} depthTest={false} depthWrite={false} opacity={0.3} transparent />
</line> </line>
{/* First corner marker */} {/* First corner marker */}
{corner1 && ( {corner1 && (
<mesh position={[corner1[0], levelY + 0.02, corner1[2]]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={2}> <CursorSphere
<ringGeometry args={[0.1, 0.15, 32]} /> position={[corner1[0], levelY + GRID_OFFSET, corner1[2]]}
<meshBasicMaterial color="#22c55e" depthTest={false} depthWrite={true} /> color="#818cf8"
</mesh> showTooltip={false}
/>
)} )}
{/* Cursor marker on ground */} {/* Thin preview fill when drawing (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 */}
{previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && ( {previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && (
<mesh <mesh
position={[previewDimensions.centerX, levelY + 0.01, previewDimensions.centerZ]} position={[previewDimensions.centerX, levelY + GRID_OFFSET, previewDimensions.centerZ]}
rotation={[-Math.PI / 2, 0, 0]} rotation={[-Math.PI / 2, 0, 0]}
> >
<planeGeometry args={[previewDimensions.length, previewDimensions.width]} /> <planeGeometry args={[previewDimensions.length, previewDimensions.width]} />
<meshBasicMaterial <meshBasicMaterial
color="#8b4513" color="#818cf8"
opacity={0.2} opacity={0.1}
transparent transparent
side={DoubleSide} side={DoubleSide}
depthTest={false} depthTest={false}
@@ -1,20 +1,93 @@
import type { ThreeElements } from '@react-three/fiber' import type { ThreeElements } from '@react-three/fiber'
import { forwardRef } from 'react' 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 color?: string
depthWrite?: boolean depthWrite?: boolean
showTooltip?: boolean
height?: number
} }
export const CursorSphere = forwardRef<Mesh, CursorSphereProps>(function CursorSphere( export const CursorSphere = forwardRef<Group, CursorSphereProps>(function CursorSphere(
{ color = '#f1c066', ...props }, { color = '#818cf8', showTooltip = true, height = 2.5, ...props },
ref, 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 ( return (
<mesh ref={ref} {...props} renderOrder={2}> <group ref={ref} {...props}>
<sphereGeometry args={[0.1, 16, 16]} /> {/* Flat marker on the ground */}
<meshBasicMaterial color={color} depthTest={false} depthWrite={true} /> <group rotation={[-Math.PI / 2, 0, 0]}>
</mesh> {/* 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 { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' 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 { sfxEmitter } from '@/lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
@@ -64,7 +64,7 @@ const commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, numb
} }
export const SlabTool: React.FC = () => { export const SlabTool: React.FC = () => {
const cursorRef = useRef<Mesh>(null) const cursorRef = useRef<Group>(null)
const mainLineRef = useRef<Line>(null!) const mainLineRef = useRef<Line>(null!)
const closingLineRef = useRef<Line>(null!) const closingLineRef = useRef<Line>(null!)
const currentLevelId = useViewer((state) => state.selection.levelId) const currentLevelId = useViewer((state) => state.selection.levelId)
@@ -248,9 +248,9 @@ export const SlabTool: React.FC = () => {
> >
<shapeGeometry args={[previewShape]} /> <shapeGeometry args={[previewShape]} />
<meshBasicMaterial <meshBasicMaterial
color="#a3a3a3" color="#818cf8"
depthTest={false} depthTest={false}
opacity={0.3} opacity={0.15}
side={DoubleSide} side={DoubleSide}
transparent transparent
/> />
@@ -261,7 +261,7 @@ export const SlabTool: React.FC = () => {
{/* @ts-ignore */} {/* @ts-ignore */}
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}> <line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry /> <bufferGeometry />
<lineBasicNodeMaterial color="#737373" linewidth={3} depthTest={false} depthWrite={false} /> <lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} />
</line> </line>
{/* Closing line */} {/* Closing line */}
@@ -269,7 +269,7 @@ export const SlabTool: React.FC = () => {
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}> <line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry /> <bufferGeometry />
<lineBasicNodeMaterial <lineBasicNodeMaterial
color="#737373" color="#818cf8"
linewidth={2} linewidth={2}
depthTest={false} depthTest={false}
depthWrite={false} depthWrite={false}
@@ -280,7 +280,7 @@ export const SlabTool: React.FC = () => {
{/* Point markers */} {/* Point markers */}
{points.map(([x, z], index) => ( {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> </group>
) )
@@ -1,7 +1,7 @@
import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core' import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react' 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 { sfxEmitter } from '@/lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
@@ -94,7 +94,7 @@ const commitWallDrawing = (start: [number, number], end: [number, number]) => {
} }
export const WallTool: React.FC = () => { export const WallTool: React.FC = () => {
const cursorRef = useRef<Mesh>(null) const cursorRef = useRef<Group>(null)
const wallPreviewRef = useRef<Mesh>(null!) const wallPreviewRef = useRef<Mesh>(null!)
const startingPoint = useRef(new Vector3(0, 0, 0)) const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = 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] 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]) 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) { if (buildingState.current === 1) {
// Snap to 45° angles only if shift is not pressed // Snap to 45° angles only if shift is not pressed
@@ -119,6 +118,9 @@ export const WallTool: React.FC = () => {
: snapTo45Degrees(startingPoint.current, cursorPosition) : snapTo45Degrees(startingPoint.current, cursorPosition)
endingPoint.current.copy(snapped) 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 // Play snap sound only when the actual wall end position changes
const currentWallEnd: [number, number] = [endingPoint.current.x, endingPoint.current.z] const currentWallEnd: [number, number] = [endingPoint.current.x, endingPoint.current.z]
if (previousWallEnd && if (previousWallEnd &&
@@ -129,6 +131,9 @@ export const WallTool: React.FC = () => {
// Update wall preview geometry // Update wall preview geometry
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current) 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}> <mesh ref={wallPreviewRef} visible={false} renderOrder={1}>
<shapeGeometry /> <shapeGeometry />
<meshBasicMaterial <meshBasicMaterial
color="#3b82f6" color="#818cf8"
transparent transparent
opacity={0.5} opacity={0.5}
side={DoubleSide} side={DoubleSide}
@@ -1,7 +1,7 @@
import { emitter, type GridEvent, useScene, ZoneNode, type LevelNode } from "@pascal-app/core"; import { emitter, type GridEvent, useScene, ZoneNode, type LevelNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from "@pascal-app/viewer";
import { useEffect, useMemo, useRef, useState } from "react"; 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 useEditor from "@/store/use-editor";
import { CursorSphere } from "../shared/cursor-sphere"; import { CursorSphere } from "../shared/cursor-sphere";
@@ -101,7 +101,7 @@ const isValidPoint = (
}; };
export const ZoneTool: React.FC = () => { export const ZoneTool: React.FC = () => {
const cursorRef = useRef<Mesh>(null); const cursorRef = useRef<Group>(null);
const mainLineRef = useRef<Line>(null!); const mainLineRef = useRef<Line>(null!);
const closingLineRef = useRef<Line>(null!); const closingLineRef = useRef<Line>(null!);
const pointsRef = useRef<Array<[number, number]>>([]); const pointsRef = useRef<Array<[number, number]>>([]);
@@ -241,9 +241,6 @@ export const ZoneTool: React.FC = () => {
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current }); setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
mainLineRef.current.visible = false; mainLineRef.current.visible = false;
closingLineRef.current.visible = false; closingLineRef.current.visible = false;
// Deactivate tool
setTool(null);
} else { } else {
// Add point to polygon // Add point to polygon
pointsRef.current = [...pointsRef.current, clickPoint]; pointsRef.current = [...pointsRef.current, clickPoint];
@@ -263,9 +260,6 @@ export const ZoneTool: React.FC = () => {
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current }); setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
mainLineRef.current.visible = false; mainLineRef.current.visible = false;
closingLineRef.current.visible = false; closingLineRef.current.visible = false;
// Deactivate tool
setTool(null);
} }
}; };
@@ -318,7 +312,7 @@ export const ZoneTool: React.FC = () => {
return ( return (
<group> <group>
{/* Cursor */} {/* Cursor */}
<CursorSphere ref={cursorRef} color="#3b82f6" /> <CursorSphere ref={cursorRef} />
{/* Preview fill */} {/* Preview fill */}
{previewShape && ( {previewShape && (
@@ -329,7 +323,7 @@ export const ZoneTool: React.FC = () => {
> >
<shapeGeometry args={[previewShape]} /> <shapeGeometry args={[previewShape]} />
<meshBasicMaterial <meshBasicMaterial
color="#3b82f6" color="#818cf8"
depthTest={false} depthTest={false}
opacity={0.15} opacity={0.15}
side={DoubleSide} side={DoubleSide}
@@ -343,7 +337,7 @@ export const ZoneTool: React.FC = () => {
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}> <line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry /> <bufferGeometry />
<lineBasicNodeMaterial <lineBasicNodeMaterial
color="#3b82f6" color="#818cf8"
linewidth={3} linewidth={3}
depthTest={false} depthTest={false}
depthWrite={false} depthWrite={false}
@@ -355,7 +349,7 @@ export const ZoneTool: React.FC = () => {
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}> <line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry /> <bufferGeometry />
<lineBasicNodeMaterial <lineBasicNodeMaterial
color="#3b82f6" color="#818cf8"
linewidth={2} linewidth={2}
depthTest={false} depthTest={false}
depthWrite={false} depthWrite={false}
@@ -367,7 +361,7 @@ export const ZoneTool: React.FC = () => {
{/* Point markers */} {/* Point markers */}
{points.map(([x, z], index) => {points.map(([x, z], index) =>
isValidPoint([x, z]) ? ( 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 ) : null
)} )}
</group> </group>
@@ -56,11 +56,18 @@ export function FurnishTools() {
const mode = useEditor((state) => state.mode); const mode = useEditor((state) => state.mode);
const activeTool = useEditor((state) => state.tool); const activeTool = useEditor((state) => state.tool);
const setActiveTool = useEditor((state) => state.setTool); const setActiveTool = useEditor((state) => state.setTool);
const setMode = useEditor((state) => state.setMode);
const catalogCategory = useEditor((state) => state.catalogCategory); const catalogCategory = useEditor((state) => state.catalogCategory);
const setCatalogCategory = useEditor((state) => state.setCatalogCategory); const setCatalogCategory = useEditor((state) => state.setCatalogCategory);
const hasActiveTool = furnishTools.some((tool) =>
mode === "build" &&
activeTool === "item" &&
catalogCategory === tool.catalogCategory
);
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 px-1">
{furnishTools.map((tool, index) => { {furnishTools.map((tool, index) => {
// For item tools with catalog category, check both tool and category match // For item tools with catalog category, check both tool and category match
const isActive = const isActive =
@@ -73,13 +80,23 @@ export function FurnishTools() {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
className={cn( className={cn(
"size-11 rounded-lg transition-all", "size-11 rounded-lg transition-all duration-300",
isActive && "bg-primary shadow-md shadow-primary/20", isActive && "bg-primary shadow-lg shadow-primary/40 ring-2 ring-primary ring-offset-2 ring-offset-zinc-950 scale-110 z-10",
!isActive && "hover:bg-white/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={() => { onClick={() => {
setCatalogCategory(tool.catalogCategory); if (isActive) {
setActiveTool("item"); setActiveTool(null);
setCatalogCategory(null);
setMode("select");
} else {
setCatalogCategory(tool.catalogCategory);
setActiveTool("item");
if (mode !== "build") {
setMode("build");
}
}
}} }}
size="icon" size="icon"
variant={isActive ? "default" : "ghost"} variant={isActive ? "default" : "ghost"}
@@ -5,7 +5,6 @@ import { cn } from "@/lib/utils";
import { CameraActions } from "./camera-actions"; import { CameraActions } from "./camera-actions";
import { ControlModes } from "./control-modes"; import { ControlModes } from "./control-modes";
import { PhaseSwitcher } from "./phase-switcher";
import { StructureTools } from "./structure-tools"; import { StructureTools } from "./structure-tools";
import useEditor from "@/store/use-editor"; import useEditor from "@/store/use-editor";
import { useReducedMotion } from "@/hooks/use-reduced-motion"; import { useReducedMotion } from "@/hooks/use-reduced-motion";
@@ -68,7 +67,7 @@ export function ActionMenu({ className }: { className?: string }) {
</AnimatePresence> </AnimatePresence>
<AnimatePresence> <AnimatePresence>
{phase === "furnish" && mode === "build" && ( {phase === "furnish" && (
<motion.div <motion.div
className={cn( className={cn(
"overflow-hidden border-zinc-800", "overflow-hidden border-zinc-800",
@@ -106,7 +105,7 @@ export function ActionMenu({ className }: { className?: string }) {
{/* Structure Tools Row - Animated */} {/* Structure Tools Row - Animated */}
<AnimatePresence> <AnimatePresence>
{phase === "structure" && mode === "build" && ( {phase === "structure" && (
<motion.div <motion.div
className={cn( className={cn(
"overflow-hidden border-zinc-800 max-h-20 border-b px-2 py-2", "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> </AnimatePresence>
{/* Control Mode Row - Always visible, centered */} {/* Control Mode Row - Always visible, centered */}
<div className="flex items-center justify-center gap-1 px-2 py-1.5"> <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 /> <ControlModes />
<div className="mx-1 h-5 w-px bg-zinc-700" /> <div className="mx-1 h-5 w-px bg-zinc-700" />
<ViewToggles /> <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 { cn } from '@/lib/utils'
import useEditor, { CatalogCategory, StructureTool, Tool } from '@/store/use-editor' import useEditor, { CatalogCategory, StructureTool, Tool } from '@/store/use-editor'
import { useContextualTools } from '@/hooks/use-contextual-tools'
export type ToolConfig = { export type ToolConfig = {
id: StructureTool; iconSrc: string; label: string; catalogCategory?: CatalogCategory } id: StructureTool; iconSrc: string; label: string; catalogCategory?: CatalogCategory }
@@ -28,28 +29,39 @@ export function StructureTools() {
const structureLayer = useEditor((state) => state.structureLayer) const structureLayer = useEditor((state) => state.structureLayer)
const setTool = useEditor((state) => state.setTool) const setTool = useEditor((state) => state.setTool)
const setCatalogCategory = useEditor((state) => state.setCatalogCategory) const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
const contextualTools = useContextualTools()
// Filter tools based on structureLayer // Filter tools based on structureLayer
const visibleTools = structureLayer === 'zones' const visibleTools = structureLayer === 'zones'
? tools.filter((t) => t.id === 'zone') ? tools.filter((t) => t.id === 'zone')
: 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 ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 px-1">
{visibleTools.map((tool, index) => { {visibleTools.map((tool, index) => {
// For item tools with catalog category, check both tool and category match // For item tools with catalog category, check both tool and category match
const isActive = const isActive =
activeTool === tool.id && activeTool === tool.id &&
(tool.catalogCategory ? catalogCategory === tool.catalogCategory : true) (tool.catalogCategory ? catalogCategory === tool.catalogCategory : true)
const isContextual = contextualTools.includes(tool.id)
return ( return (
<Tooltip key={`${tool.id}-${tool.catalogCategory ?? index}`}> <Tooltip key={`${tool.id}-${tool.catalogCategory ?? index}`}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
className={cn( className={cn(
'size-11 rounded-lg transition-all', 'size-11 rounded-lg transition-all duration-300',
isActive && 'bg-primary shadow-md shadow-primary/20', isActive && 'bg-primary shadow-lg shadow-primary/40 ring-2 ring-primary ring-offset-2 ring-offset-zinc-950 scale-110 z-10',
!isActive && 'hover:bg-white/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={() => { onClick={() => {
if (isActive) { if (isActive) {
@@ -58,6 +70,11 @@ export function StructureTools() {
} else { } else {
setTool(tool.id) setTool(tool.id)
setCatalogCategory(tool.catalogCategory ?? null) 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" size="icon"
@@ -133,8 +133,8 @@ export function ViewToggles() {
className={cn( className={cn(
'h-8 w-8 text-zinc-400 transition-all p-0', 'h-8 w-8 text-zinc-400 transition-all p-0',
wallMode !== 'cutaway' wallMode !== 'cutaway'
? 'bg-emerald-500/20 text-emerald-400' ? 'bg-white/10'
: 'hover:bg-zinc-800', : 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
)} )}
onClick={cycleWallMode} onClick={cycleWallMode}
size="icon" size="icon"
@@ -156,16 +156,16 @@ export function ViewToggles() {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
className={cn( className={cn(
'h-8 w-8 text-zinc-400 transition-all', 'h-8 w-8 text-zinc-400 transition-all p-0',
showScans showScans
? 'bg-cyan-500/20 text-cyan-400' ? 'bg-white/10'
: 'hover:bg-zinc-800', : 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
)} )}
onClick={() => setShowScans(!showScans)} onClick={() => setShowScans(!showScans)}
size="icon" size="icon"
variant="ghost" variant="ghost"
> >
<Box className="h-4 w-4" /> <img alt="Scans" className="h-5 w-5 object-contain" src="/icons/mesh.png" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@@ -178,16 +178,16 @@ export function ViewToggles() {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
className={cn( className={cn(
'h-8 w-8 text-zinc-400 transition-all', 'h-8 w-8 text-zinc-400 transition-all p-0',
showGuides showGuides
? 'bg-purple-500/20 text-purple-400' ? 'bg-white/10'
: 'hover:bg-zinc-800', : 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
)} )}
onClick={() => setShowGuides(!showGuides)} onClick={() => setShowGuides(!showGuides)}
size="icon" size="icon"
variant="ghost" variant="ghost"
> >
<Image className="h-4 w-4" /> <img alt="Guides" className="h-5 w-5 object-contain" src="/icons/floorplan.png" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@@ -119,16 +119,16 @@ export function CeilingPanel() {
return ( 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"> <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 */} {/* 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"> <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" /> <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²)`} {node.name || `Ceiling (${area.toFixed(1)}m²)`}
</h2> </h2>
</div> </div>
<button <button
type="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} onClick={handleClose}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -140,7 +140,7 @@ export function CeilingPanel() {
<div className="space-y-4"> <div className="space-y-4">
{/* Height */} {/* Height */}
<div className="space-y-2"> <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 Height
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -162,27 +162,27 @@ export function CeilingPanel() {
{/* Quick preset buttons */} {/* Quick preset buttons */}
<div className="space-y-2"> <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 Presets
</label> </label>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<button <button
type="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 })} onClick={() => handleUpdate({ height: 2.4 })}
> >
Low (2.4m) Low (2.4m)
</button> </button>
<button <button
type="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 })} onClick={() => handleUpdate({ height: 2.5 })}
> >
Standard (2.5m) Standard (2.5m)
</button> </button>
<button <button
type="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 })} onClick={() => handleUpdate({ height: 3.0 })}
> >
High (3m) High (3m)
@@ -192,10 +192,10 @@ export function CeilingPanel() {
{/* Area info */} {/* Area info */}
<div className="space-y-2"> <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 Area
</label> </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² {area.toFixed(2)} m²
</div> </div>
</div> </div>
@@ -203,13 +203,13 @@ export function CeilingPanel() {
{/* Holes */} {/* Holes */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <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 Holes
</label> </label>
{editingHole?.nodeId === selectedId ? ( {editingHole?.nodeId === selectedId ? (
<button <button
type="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)} onClick={() => setEditingHole(null)}
> >
<span>Done Editing</span> <span>Done Editing</span>
@@ -217,7 +217,7 @@ export function CeilingPanel() {
) : ( ) : (
<button <button
type="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} onClick={handleAddHole}
> >
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
@@ -233,10 +233,10 @@ export function CeilingPanel() {
return ( return (
<div <div
key={index} 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 isEditing
? 'border-green-500 bg-green-500/10' ? 'border-green-500 bg-green-500/10 ring-1 ring-green-500/20'
: 'border-border bg-muted/30' : 'border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30'
}`} }`}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
+26 -26
View File
@@ -138,16 +138,16 @@ export function DoorPanel() {
return ( 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)]"> <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 */} {/* 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"> <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" /> <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)`} {node.name || `Door (${node.width}×${node.height}m)`}
</h2> </h2>
</div> </div>
<button <button
type="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} onClick={handleClose}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -159,7 +159,7 @@ export function DoorPanel() {
{/* Position */} {/* Position */}
<div className="space-y-2"> <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 Position
</label> </label>
<div className="grid grid-cols-1 gap-2"> <div className="grid grid-cols-1 gap-2">
@@ -172,7 +172,7 @@ export function DoorPanel() {
</div> </div>
<button <button
type="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} onClick={handleFlip}
> >
<FlipHorizontal2 className="h-3.5 w-3.5" /> <FlipHorizontal2 className="h-3.5 w-3.5" />
@@ -182,7 +182,7 @@ export function DoorPanel() {
{/* Dimensions */} {/* Dimensions */}
<div className="space-y-2"> <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 Dimensions
</label> </label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -213,7 +213,7 @@ export function DoorPanel() {
{/* Frame */} {/* Frame */}
<div className="space-y-2"> <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 Frame
</label> </label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -246,7 +246,7 @@ export function DoorPanel() {
{/* Content Padding */} {/* Content Padding */}
<div className="space-y-2"> <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 Content Padding
</label> </label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -279,22 +279,22 @@ export function DoorPanel() {
{/* Swing */} {/* Swing */}
<div className="space-y-2"> <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 Swing
</label> </label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-1"> <div className="space-y-1">
<span className="text-xs text-muted-foreground">Hinges</span> <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) => ( {(['left', 'right'] as const).map((side) => (
<button <button
key={side} key={side}
type="button" type="button"
onClick={() => handleUpdate({ hingesSide: side })} 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 node.hingesSide === side
? 'border-primary bg-primary text-primary-foreground' ? 'bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 text-foreground'
: 'border-border hover:bg-accent' : 'text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50'
}`} }`}
> >
{side.charAt(0).toUpperCase() + side.slice(1)} {side.charAt(0).toUpperCase() + side.slice(1)}
@@ -304,7 +304,7 @@ export function DoorPanel() {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<span className="text-xs text-muted-foreground">Direction</span> <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) => ( {(['inward', 'outward'] as const).map((dir) => (
<button <button
key={dir} key={dir}
@@ -327,7 +327,7 @@ export function DoorPanel() {
{/* Threshold */} {/* Threshold */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <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 Threshold
</label> </label>
<Switch <Switch
@@ -354,7 +354,7 @@ export function DoorPanel() {
{/* Handle */} {/* Handle */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <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 Handle
</label> </label>
<Switch <Switch
@@ -379,7 +379,7 @@ export function DoorPanel() {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<span className="text-xs text-muted-foreground">Side</span> <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) => ( {(['left', 'right'] as const).map((side) => (
<button <button
key={side} key={side}
@@ -402,7 +402,7 @@ export function DoorPanel() {
{/* Hardware */} {/* Hardware */}
<div className="space-y-2"> <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 Hardware
</label> </label>
<div className="space-y-2"> <div className="space-y-2">
@@ -440,7 +440,7 @@ export function DoorPanel() {
{/* Segments */} {/* Segments */}
<div className="space-y-2"> <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) Leaf segments (top bottom)
</label> </label>
{node.segments.map((seg, i) => { {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 key={i} className="rounded border border-border p-2 space-y-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">Segment {i + 1}</span> <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) => ( {(['panel', 'glass', 'empty'] as const).map((t) => (
<button <button
key={t} key={t}
@@ -584,7 +584,7 @@ export function DoorPanel() {
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="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={() => { onClick={() => {
const updated = [ const updated = [
...node.segments, ...node.segments,
@@ -598,7 +598,7 @@ export function DoorPanel() {
{node.segments.length > 1 && ( {node.segments.length > 1 && (
<button <button
type="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={() => { onClick={() => {
handleUpdate({ segments: node.segments.slice(0, -1) }) handleUpdate({ segments: node.segments.slice(0, -1) })
}} }}
@@ -611,11 +611,11 @@ export function DoorPanel() {
</div> </div>
{/* Action Buttons */} {/* 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"> <div className="flex gap-2">
<button <button
type="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} onClick={handleMove}
> >
<Move className="h-3.5 w-3.5" /> <Move className="h-3.5 w-3.5" />
@@ -623,7 +623,7 @@ export function DoorPanel() {
</button> </button>
<button <button
type="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} onClick={handleDuplicate}
> >
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
@@ -631,7 +631,7 @@ export function DoorPanel() {
</button> </button>
<button <button
type="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} onClick={handleDelete}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
+12 -12
View File
@@ -84,7 +84,7 @@ export function ItemPanel() {
return ( 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"> <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 */} {/* 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"> <div className="flex items-center gap-2 min-w-0">
<Image <Image
src={node.asset.thumbnail || '/icons/furniture.png'} src={node.asset.thumbnail || '/icons/furniture.png'}
@@ -93,13 +93,13 @@ export function ItemPanel() {
height={16} height={16}
className="shrink-0 object-contain" 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} {node.name || node.asset.name}
</h2> </h2>
</div> </div>
<button <button
type="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} onClick={handleClose}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -111,7 +111,7 @@ export function ItemPanel() {
<div className="space-y-4"> <div className="space-y-4">
{/* Position */} {/* Position */}
<div className="space-y-2"> <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 Position
</label> </label>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
@@ -144,7 +144,7 @@ export function ItemPanel() {
{/* Rotation */} {/* Rotation */}
<div className="space-y-2"> <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 Rotation
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -193,7 +193,7 @@ export function ItemPanel() {
{/* Scale */} {/* Scale */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <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 Scale
</label> </label>
<button <button
@@ -251,10 +251,10 @@ export function ItemPanel() {
{/* Dimensions (effective, read-only) */} {/* Dimensions (effective, read-only) */}
<div className="space-y-2"> <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 Dimensions
</label> </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) 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` 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> </div>
{/* Action Buttons */} {/* 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"> <div className="flex gap-2">
<button <button
type="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} onClick={handleMove}
> >
<Move className="h-3.5 w-3.5" /> <Move className="h-3.5 w-3.5" />
@@ -277,7 +277,7 @@ export function ItemPanel() {
</button> </button>
<button <button
type="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} onClick={handleDuplicate}
> >
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
@@ -285,7 +285,7 @@ export function ItemPanel() {
</button> </button>
<button <button
type="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} onClick={handleDelete}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -37,19 +37,19 @@ export function ReferencePanel() {
return ( 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"> <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 */} {/* 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"> <div className="flex items-center gap-2 min-w-0">
{isScan ? ( {isScan ? (
<Box className="h-4 w-4 shrink-0 text-muted-foreground" /> <Box className="h-4 w-4 shrink-0 text-muted-foreground" />
) : ( ) : (
<Image 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')} {node.name || (isScan ? '3D Scan' : 'Guide Image')}
</h2> </h2>
</div> </div>
<button <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} onClick={handleClose}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -61,7 +61,7 @@ export function ReferencePanel() {
<div className="space-y-4"> <div className="space-y-4">
{/* Position */} {/* Position */}
<div className="space-y-2"> <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 Position
</label> </label>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
@@ -83,7 +83,7 @@ export function ReferencePanel() {
{/* Rotation Y */} {/* Rotation Y */}
<div className="space-y-2"> <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 Rotation
</label> </label>
<div className="flex items-center gap-1.5"> <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> <span className="text-muted-foreground text-xs shrink-0">&deg;</span>
<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={() => onClick={() =>
handleUpdate({ handleUpdate({
rotation: [node.rotation[0], node.rotation[1] - Math.PI / 4, node.rotation[2]], rotation: [node.rotation[0], node.rotation[1] - Math.PI / 4, node.rotation[2]],
@@ -111,7 +111,7 @@ export function ReferencePanel() {
&minus;45 &minus;45
</button> </button>
<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={() => onClick={() =>
handleUpdate({ handleUpdate({
rotation: [node.rotation[0], node.rotation[1] + Math.PI / 4, node.rotation[2]], rotation: [node.rotation[0], node.rotation[1] + Math.PI / 4, node.rotation[2]],
@@ -125,7 +125,7 @@ export function ReferencePanel() {
{/* Scale */} {/* Scale */}
<div className="space-y-2"> <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 Scale
</label> </label>
<NumberInput <NumberInput
@@ -144,10 +144,10 @@ export function ReferencePanel() {
{/* Opacity */} {/* Opacity */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between"> <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 Opacity
</label> </label>
<span className="text-muted-foreground text-xs">{node.opacity}%</span> <span className="text-muted-foreground font-mono text-xs">{node.opacity}%</span>
</div> </div>
<input <input
className="w-full cursor-pointer" className="w-full cursor-pointer"
+16 -16
View File
@@ -39,16 +39,16 @@ export function RoofPanel() {
return ( 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"> <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 */} {/* 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"> <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" /> <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'} {node.name || 'Gable Roof'}
</h2> </h2>
</div> </div>
<button <button
type="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} onClick={handleClose}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -60,12 +60,12 @@ export function RoofPanel() {
<div className="space-y-4"> <div className="space-y-4">
{/* Length */} {/* Length */}
<div className="space-y-2"> <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 Length
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <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" min="0.5"
onChange={(e) => { onChange={(e) => {
const value = Number.parseFloat(e.target.value) const value = Number.parseFloat(e.target.value)
@@ -83,12 +83,12 @@ export function RoofPanel() {
{/* Height */} {/* Height */}
<div className="space-y-2"> <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 Height
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <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" min="0.1"
onChange={(e) => { onChange={(e) => {
const value = Number.parseFloat(e.target.value) const value = Number.parseFloat(e.target.value)
@@ -107,7 +107,7 @@ export function RoofPanel() {
{/* Slope Widths */} {/* Slope Widths */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <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 Slope Widths
</label> </label>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
@@ -119,7 +119,7 @@ export function RoofPanel() {
<label className="text-muted-foreground text-xs">Left</label> <label className="text-muted-foreground text-xs">Left</label>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<input <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" min="0.1"
onChange={(e) => { onChange={(e) => {
const value = Number.parseFloat(e.target.value) const value = Number.parseFloat(e.target.value)
@@ -138,7 +138,7 @@ export function RoofPanel() {
<label className="text-muted-foreground text-xs">Right</label> <label className="text-muted-foreground text-xs">Right</label>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<input <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" min="0.1"
onChange={(e) => { onChange={(e) => {
const value = Number.parseFloat(e.target.value) const value = Number.parseFloat(e.target.value)
@@ -158,12 +158,12 @@ export function RoofPanel() {
{/* Rotation */} {/* Rotation */}
<div className="space-y-2"> <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 Rotation
</label> </label>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<input <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) => { onChange={(e) => {
const degrees = Number.parseFloat(e.target.value) const degrees = Number.parseFloat(e.target.value)
if (!Number.isNaN(degrees)) { if (!Number.isNaN(degrees)) {
@@ -178,7 +178,7 @@ export function RoofPanel() {
<span className="text-muted-foreground text-xs shrink-0">&deg;</span> <span className="text-muted-foreground text-xs shrink-0">&deg;</span>
<button <button
type="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={() => { onClick={() => {
const newRotation = node.rotation - Math.PI / 2 const newRotation = node.rotation - Math.PI / 2
handleUpdate({ rotation: newRotation }) handleUpdate({ rotation: newRotation })
@@ -188,7 +188,7 @@ export function RoofPanel() {
</button> </button>
<button <button
type="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={() => { onClick={() => {
const newRotation = node.rotation + Math.PI / 2 const newRotation = node.rotation + Math.PI / 2
handleUpdate({ rotation: newRotation }) handleUpdate({ rotation: newRotation })
@@ -201,7 +201,7 @@ export function RoofPanel() {
{/* Position */} {/* Position */}
<div className="space-y-2"> <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 Position
</label> </label>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
@@ -209,7 +209,7 @@ export function RoofPanel() {
<div key={i} className="space-y-1"> <div key={i} className="space-y-1">
<label className="text-muted-foreground text-xs">{['X', 'Y', 'Z'][i]}</label> <label className="text-muted-foreground text-xs">{['X', 'Y', 'Z'][i]}</label>
<input <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) => { onChange={(e) => {
const value = Number.parseFloat(e.target.value) const value = Number.parseFloat(e.target.value)
if (!Number.isNaN(value)) { if (!Number.isNaN(value)) {
+17 -17
View File
@@ -119,16 +119,16 @@ export function SlabPanel() {
return ( 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"> <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 */} {/* 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"> <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" /> <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²)`} {node.name || `Slab (${area.toFixed(1)}m²)`}
</h2> </h2>
</div> </div>
<button <button
type="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} onClick={handleClose}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -140,7 +140,7 @@ export function SlabPanel() {
<div className="space-y-4"> <div className="space-y-4">
{/* Elevation */} {/* Elevation */}
<div className="space-y-2"> <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 Elevation
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -162,34 +162,34 @@ export function SlabPanel() {
{/* Quick preset buttons */} {/* Quick preset buttons */}
<div className="space-y-2"> <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 Presets
</label> </label>
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
<button <button
type="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 })} onClick={() => handleUpdate({ elevation: -0.15 })}
> >
Sunken (-15cm) Sunken (-15cm)
</button> </button>
<button <button
type="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 })} onClick={() => handleUpdate({ elevation: 0 })}
> >
Ground (0m) Ground (0m)
</button> </button>
<button <button
type="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 })} onClick={() => handleUpdate({ elevation: 0.05 })}
> >
Raised (5cm) Raised (5cm)
</button> </button>
<button <button
type="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 })} onClick={() => handleUpdate({ elevation: 0.15 })}
> >
Step (15cm) Step (15cm)
@@ -199,10 +199,10 @@ export function SlabPanel() {
{/* Area info */} {/* Area info */}
<div className="space-y-2"> <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 Area
</label> </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² {area.toFixed(2)} m²
</div> </div>
</div> </div>
@@ -210,13 +210,13 @@ export function SlabPanel() {
{/* Holes */} {/* Holes */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <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 Holes
</label> </label>
{editingHole?.nodeId === selectedId ? ( {editingHole?.nodeId === selectedId ? (
<button <button
type="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)} onClick={() => setEditingHole(null)}
> >
<span>Done Editing</span> <span>Done Editing</span>
@@ -224,7 +224,7 @@ export function SlabPanel() {
) : ( ) : (
<button <button
type="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} onClick={handleAddHole}
> >
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
@@ -240,10 +240,10 @@ export function SlabPanel() {
return ( return (
<div <div
key={index} 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 isEditing
? 'border-green-500 bg-green-500/10' ? 'border-green-500 bg-green-500/10 ring-1 ring-green-500/20'
: 'border-border bg-muted/30' : 'border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30'
}`} }`}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -43,16 +43,16 @@ export function WallPanel() {
return ( 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"> <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 */} {/* 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"> <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" /> <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)`} {node.name || `Wall (${length.toFixed(2)}m)`}
</h2> </h2>
</div> </div>
<button <button
type="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} onClick={handleClose}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -64,7 +64,7 @@ export function WallPanel() {
{/* Dimensions */} {/* Dimensions */}
<div className="space-y-2"> <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 Dimensions
</label> </label>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -95,10 +95,10 @@ export function WallPanel() {
{/* Info */} {/* Info */}
<div className="space-y-2"> <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 Info
</label> </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 Length: {length.toFixed(2)} m
</div> </div>
</div> </div>
@@ -127,16 +127,16 @@ export function WindowPanel() {
return ( 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"> <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 */} {/* 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"> <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" /> <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)`} {node.name || `Window (${node.width}×${node.height}m)`}
</h2> </h2>
</div> </div>
<button <button
type="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} onClick={handleClose}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -148,7 +148,7 @@ export function WindowPanel() {
{/* Position */} {/* Position */}
<div className="space-y-2"> <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 Position
</label> </label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -167,7 +167,7 @@ export function WindowPanel() {
</div> </div>
<button <button
type="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} onClick={handleFlip}
> >
<FlipHorizontal2 className="h-3.5 w-3.5" /> <FlipHorizontal2 className="h-3.5 w-3.5" />
@@ -177,7 +177,7 @@ export function WindowPanel() {
{/* Dimensions */} {/* Dimensions */}
<div className="space-y-2"> <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 Dimensions
</label> </label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -208,7 +208,7 @@ export function WindowPanel() {
{/* Frame */} {/* Frame */}
<div className="space-y-2"> <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 Frame
</label> </label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -241,7 +241,7 @@ export function WindowPanel() {
{/* Grid */} {/* Grid */}
<div className="space-y-2"> <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 Grid
</label> </label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -343,7 +343,7 @@ export function WindowPanel() {
{/* Sill */} {/* Sill */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <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 Sill
</label> </label>
<Switch <Switch
@@ -383,11 +383,11 @@ export function WindowPanel() {
</div> </div>
{/* Action Buttons */} {/* 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"> <div className="flex gap-2">
<button <button
type="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} onClick={handleMove}
> >
<Move className="h-3.5 w-3.5" /> <Move className="h-3.5 w-3.5" />
@@ -395,7 +395,7 @@ export function WindowPanel() {
</button> </button>
<button <button
type="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} onClick={handleDuplicate}
> >
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
@@ -403,7 +403,7 @@ export function WindowPanel() {
</button> </button>
<button <button
type="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} onClick={handleDelete}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -5,7 +5,7 @@ import type * as React from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const buttonVariants = cva( 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: { variants: {
variant: { variant: {
@@ -45,7 +45,7 @@ function ContextMenuSubTrigger({
return ( return (
<ContextMenuPrimitive.SubTrigger <ContextMenuPrimitive.SubTrigger
className={cn( 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, className,
)} )}
data-inset={inset} data-inset={inset}
@@ -104,7 +104,7 @@ function ContextMenuItem({
return ( return (
<ContextMenuPrimitive.Item <ContextMenuPrimitive.Item
className={cn( 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, className,
)} )}
data-inset={inset} data-inset={inset}
@@ -125,7 +125,7 @@ function ContextMenuCheckboxItem({
<ContextMenuPrimitive.CheckboxItem <ContextMenuPrimitive.CheckboxItem
checked={checked} checked={checked}
className={cn( 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, className,
)} )}
data-slot="context-menu-checkbox-item" data-slot="context-menu-checkbox-item"
@@ -149,7 +149,7 @@ function ContextMenuRadioItem({
return ( return (
<ContextMenuPrimitive.RadioItem <ContextMenuPrimitive.RadioItem
className={cn( 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, className,
)} )}
data-slot="context-menu-radio-item" data-slot="context-menu-radio-item"
@@ -174,7 +174,7 @@ function ContextMenuLabel({
}) { }) {
return ( return (
<ContextMenuPrimitive.Label <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-inset={inset}
data-slot="context-menu-label" data-slot="context-menu-label"
{...props} {...props}
@@ -58,7 +58,7 @@ function DropdownMenuItem({
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
className={cn( 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, className,
)} )}
data-inset={inset} data-inset={inset}
@@ -79,7 +79,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
checked={checked} checked={checked}
className={cn( 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, className,
)} )}
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
@@ -109,7 +109,7 @@ function DropdownMenuRadioItem({
return ( return (
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
className={cn( 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, className,
)} )}
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
@@ -134,7 +134,7 @@ function DropdownMenuLabel({
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <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-inset={inset}
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
{...props} {...props}
@@ -180,7 +180,7 @@ function DropdownMenuSubTrigger({
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
className={cn( 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, className,
)} )}
data-inset={inset} data-inset={inset}
@@ -2,6 +2,7 @@
import { useScene } from '@pascal-app/core' import { useScene } from '@pascal-app/core'
import { useCallback, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import NumberFlow from '@number-flow/react'
interface NumberInputProps { interface NumberInputProps {
label: string label: string
@@ -29,7 +30,7 @@ export function NumberInput({
const [inputValue, setInputValue] = useState(value.toFixed(precision)) const [inputValue, setInputValue] = useState(value.toFixed(precision))
const startXRef = useRef(0) const startXRef = useRef(0)
const startValueRef = useRef(0) const startValueRef = useRef(0)
const labelRef = useRef<HTMLLabelElement>(null) const labelRef = useRef<HTMLDivElement>(null)
const clamp = useCallback( const clamp = useCallback(
(val: number) => { (val: number) => {
@@ -131,22 +132,32 @@ export function NumberInput({
) )
return ( return (
<div className={`${className}`}> <div className={`${className} relative group/input`}>
<div className="flex items-center rounded border border-input bg-muted/30 overflow-hidden"> <div
<label 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} ref={labelRef}
className={`px-2 py-1 text-muted-foreground text-xs select-none ${ 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' : 'hover:cursor-ew-resize hover:text-foreground' isDragging ? 'cursor-ew-resize text-foreground' : 'hover:cursor-ew-resize hover:text-foreground'
} transition-colors`} } transition-colors`}
onMouseDown={handleLabelMouseDown} onMouseDown={handleLabelMouseDown}
> >
{label} {label}
</label> </div>
{isEditing ? ( {isEditing ? (
<input <input
autoFocus autoFocus
size={1} 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} onBlur={handleInputBlur}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
@@ -155,10 +166,13 @@ export function NumberInput({
/> />
) : ( ) : (
<div <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} onClick={handleValueClick}
> >
{value.toFixed(precision)} <NumberFlow
value={Number(value.toFixed(precision))}
format={{ minimumFractionDigits: precision, maximumFractionDigits: precision }}
/>
</div> </div>
)} )}
</div> </div>
@@ -404,7 +404,7 @@ function SidebarGroupLabel({
return ( return (
<Slot <Slot
className={cn( 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", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className, className,
)} )}
@@ -419,7 +419,7 @@ function SidebarGroupLabel({
return ( return (
<div <div
className={cn( 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", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className, className,
)} )}
@@ -504,7 +504,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
} }
const sidebarMenuButtonVariants = cva( 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: { variants: {
variant: { variant: {
@@ -735,7 +735,7 @@ function SidebarMenuSubButton({
isActive?: boolean; isActive?: boolean;
}) { }) {
const classes = cn( 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", "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs", size === "sm" && "text-xs",
size === "md" && "text-sm", size === "md" && "text-sm",
@@ -40,7 +40,7 @@ function TooltipContent({
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipPrimitive.Content <TooltipPrimitive.Content
className={cn( 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, className,
)} )}
data-slot="tooltip-content" data-slot="tooltip-content"
@@ -30,8 +30,6 @@ export function AppSidebar() {
switch (activePanel) { switch (activePanel) {
case "site": case "site":
return "Site"; return "Site";
case "collections":
return "Collections";
case "settings": case "settings":
return "Settings"; return "Settings";
default: default:
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Building2, Layers, Settings } from "lucide-react"; import { Building2, Settings } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { import {
@@ -10,7 +10,7 @@ import {
} from "@/components/ui/primitives/tooltip"; } from "@/components/ui/primitives/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export type PanelId = "site" | "collections" | "settings"; export type PanelId = "site" | "settings";
interface IconRailProps { interface IconRailProps {
activePanel: PanelId; activePanel: PanelId;
@@ -20,7 +20,6 @@ interface IconRailProps {
const panels: { id: PanelId; icon: typeof Building2; label: string }[] = [ const panels: { id: PanelId; icon: typeof Building2; label: string }[] = [
{ id: "site", icon: Building2, label: "Site" }, { id: "site", icon: Building2, label: "Site" },
{ id: "collections", icon: Layers, label: "Collections" },
{ id: "settings", icon: Settings, label: "Settings" }, { id: "settings", icon: Settings, label: "Settings" },
]; ];
@@ -2,7 +2,7 @@ import { CeilingNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from "@pascal-app/viewer";
import Image from "next/image"; import Image from "next/image";
import { useState } from "react"; import { useState } from "react";
import { RenamePopover } from "./rename-popover"; import { InlineRenameInput } from "./inline-rename-input";
import { TreeNode, TreeNodeWrapper } from "./tree-node"; import { TreeNode, TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions"; import { TreeNodeActions } from "./tree-node-actions";
@@ -13,7 +13,7 @@ interface CeilingTreeNodeProps {
export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) { export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [renameOpen, setRenameOpen] = useState(false); const [isEditing, setIsEditing] = useState(false);
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id)); const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
const isHovered = useViewer((state) => state.hoveredId === node.id); const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection); const setSelection = useViewer((state) => state.setSelection);
@@ -24,7 +24,7 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
}; };
const handleDoubleClick = () => { const handleDoubleClick = () => {
setRenameOpen(true); setIsEditing(true);
}; };
const handleMouseEnter = () => { const handleMouseEnter = () => {
@@ -40,33 +40,34 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
const defaultName = `Ceiling (${area}m²)`; const defaultName = `Ceiling (${area}m²)`;
return ( return (
<RenamePopover <TreeNodeWrapper
node={node} icon={<Image src="/icons/ceiling.png" alt="" width={14} height={14} className="object-contain" />}
open={renameOpen} label={
onOpenChange={setRenameOpen} <InlineRenameInput
defaultName={defaultName} node={node}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={defaultName}
/>
}
depth={depth}
hasChildren={node.children.length > 0}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
isVisible={node.visible !== false}
actions={<TreeNodeActions node={node} />}
> >
<TreeNodeWrapper {node.children.map((childId) => (
icon={<Image src="/icons/ceiling.png" alt="" width={14} height={14} className="object-contain" />} <TreeNode key={childId} nodeId={childId} depth={depth + 1} />
label={node.name || defaultName} ))}
depth={depth} </TreeNodeWrapper>
hasChildren={node.children.length > 0}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
isVisible={node.visible !== false}
actions={<TreeNodeActions node={node} />}
>
{node.children.map((childId) => (
<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 { useViewer } from "@pascal-app/viewer"
import Image from "next/image" import Image from "next/image"
import { useState } from "react" import { useState } from "react"
import { RenamePopover } from "./rename-popover" import { InlineRenameInput } from "./inline-rename-input"
import { TreeNodeWrapper } from "./tree-node" import { TreeNodeWrapper } from "./tree-node"
import { TreeNodeActions } from "./tree-node-actions" import { TreeNodeActions } from "./tree-node-actions"
@@ -14,7 +14,7 @@ interface DoorTreeNodeProps {
} }
export function DoorTreeNode({ node, depth }: 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 isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id))
const isHovered = useViewer((state) => state.hoveredId === node.id) const isHovered = useViewer((state) => state.hoveredId === node.id)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
@@ -23,28 +23,29 @@ export function DoorTreeNode({ node, depth }: DoorTreeNodeProps) {
const defaultName = `Door (${node.width}×${node.height}m)` const defaultName = `Door (${node.width}×${node.height}m)`
return ( return (
<RenamePopover <TreeNodeWrapper
node={node} icon={<Image src="/icons/door.png" alt="" width={14} height={14} className="object-contain" />}
open={renameOpen} label={
onOpenChange={setRenameOpen} <InlineRenameInput
defaultName={defaultName} node={node}
> isEditing={isEditing}
<TreeNodeWrapper onStopEditing={() => setIsEditing(false)}
icon={<Image src="/icons/door.png" alt="" width={14} height={14} className="object-contain" />} onStartEditing={() => setIsEditing(true)}
label={node.name || defaultName} defaultName={defaultName}
depth={depth} />
hasChildren={false} }
expanded={false} depth={depth}
onToggle={() => {}} hasChildren={false}
onClick={() => setSelection({ selectedIds: [node.id] })} expanded={false}
onDoubleClick={() => setRenameOpen(true)} onToggle={() => {}}
onMouseEnter={() => setHoveredId(node.id)} onClick={() => setSelection({ selectedIds: [node.id] })}
onMouseLeave={() => setHoveredId(null)} onDoubleClick={() => setIsEditing(true)}
isSelected={isSelected} onMouseEnter={() => setHoveredId(node.id)}
isHovered={isHovered} onMouseLeave={() => setHoveredId(null)}
isVisible={node.visible !== false} isSelected={isSelected}
actions={<TreeNodeActions node={node} />} isHovered={isHovered}
/> isVisible={node.visible !== false}
</RenamePopover> actions={<TreeNodeActions node={node} />}
/>
) )
} }
File diff suppressed because it is too large Load Diff
@@ -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 { useViewer } from "@pascal-app/viewer";
import Image from "next/image"; import Image from "next/image";
import { useState } from "react"; import { useState } from "react";
import { RenamePopover } from "./rename-popover"; import { InlineRenameInput } from "./inline-rename-input";
import { TreeNode, TreeNodeWrapper } from "./tree-node"; import { TreeNode, TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions"; import { TreeNodeActions } from "./tree-node-actions";
@@ -22,7 +22,7 @@ interface ItemTreeNodeProps {
} }
export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) { export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
const [renameOpen, setRenameOpen] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png"; const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png";
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id)); const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
@@ -35,7 +35,7 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
}; };
const handleDoubleClick = () => { const handleDoubleClick = () => {
setRenameOpen(true); setIsEditing(true);
}; };
const handleMouseEnter = () => { const handleMouseEnter = () => {
@@ -50,32 +50,33 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
const hasChildren = node.children && node.children.length > 0; const hasChildren = node.children && node.children.length > 0;
return ( return (
<RenamePopover <TreeNodeWrapper
node={node} icon={<Image src={iconSrc} alt="" width={14} height={14} className="object-contain" />}
open={renameOpen} label={
onOpenChange={setRenameOpen} <InlineRenameInput
defaultName={defaultName} node={node}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={defaultName}
/>
}
depth={depth}
hasChildren={hasChildren}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
isVisible={node.visible !== false}
actions={<TreeNodeActions node={node} />}
> >
<TreeNodeWrapper {hasChildren && node.children.map((childId) => (
icon={<Image src={iconSrc} alt="" width={14} height={14} className="object-contain" />} <TreeNode key={childId} nodeId={childId} depth={depth + 1} />
label={node.name || defaultName} ))}
depth={depth} </TreeNodeWrapper>
hasChildren={hasChildren}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
isVisible={node.visible !== false}
actions={<TreeNodeActions node={node} />}
>
{hasChildren && node.children.map((childId) => (
<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 { useViewer } from "@pascal-app/viewer";
import { Layers } from "lucide-react"; import { Layers } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { RenamePopover } from "./rename-popover"; import { InlineRenameInput } from "./inline-rename-input";
import { TreeNode, TreeNodeWrapper } from "./tree-node"; import { TreeNode, TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions"; import { TreeNodeActions } from "./tree-node-actions";
@@ -13,7 +13,7 @@ interface LevelTreeNodeProps {
export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) { export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) {
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const [renameOpen, setRenameOpen] = useState(false); const [isEditing, setIsEditing] = useState(false);
const isSelected = useViewer((state) => state.selection.levelId === node.id); const isSelected = useViewer((state) => state.selection.levelId === node.id);
const isHovered = useViewer((state) => state.hoveredId === node.id); const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection); const setSelection = useViewer((state) => state.setSelection);
@@ -23,35 +23,36 @@ export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) {
}; };
const handleDoubleClick = () => { const handleDoubleClick = () => {
setRenameOpen(true); setIsEditing(true);
}; };
const defaultName = `Level ${node.level}`; const defaultName = `Level ${node.level}`;
return ( return (
<RenamePopover <TreeNodeWrapper
node={node} icon={<Layers className="w-3.5 h-3.5" />}
open={renameOpen} label={
onOpenChange={setRenameOpen} <InlineRenameInput
defaultName={defaultName} node={node}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={defaultName}
/>
}
depth={depth}
hasChildren={node.children.length > 0}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
isSelected={isSelected}
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
> >
<TreeNodeWrapper {node.children.map((childId) => (
icon={<Layers className="w-3.5 h-3.5" />} <TreeNode key={childId} nodeId={childId} depth={depth + 1} />
label={node.name || defaultName} ))}
depth={depth} </TreeNodeWrapper>
hasChildren={node.children.length > 0}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
isSelected={isSelected}
isHovered={isHovered}
actions={<TreeNodeActions node={node} />}
>
{node.children.map((childId) => (
<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 { useViewer } from "@pascal-app/viewer";
import Image from "next/image"; import Image from "next/image";
import { useState } from "react"; import { useState } from "react";
import { RenamePopover } from "./rename-popover"; import { InlineRenameInput } from "./inline-rename-input";
import { TreeNodeWrapper } from "./tree-node"; import { TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions"; import { TreeNodeActions } from "./tree-node-actions";
@@ -12,7 +12,7 @@ interface RoofTreeNodeProps {
} }
export function RoofTreeNode({ node, depth }: 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 isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
const isHovered = useViewer((state) => state.hoveredId === node.id); const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection); const setSelection = useViewer((state) => state.setSelection);
@@ -23,7 +23,7 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
}; };
const handleDoubleClick = () => { const handleDoubleClick = () => {
setRenameOpen(true); setIsEditing(true);
}; };
const handleMouseEnter = () => { const handleMouseEnter = () => {
@@ -40,28 +40,29 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
const defaultName = `Roof (${sizeLabel})`; const defaultName = `Roof (${sizeLabel})`;
return ( return (
<RenamePopover <TreeNodeWrapper
node={node} icon={<Image src="/icons/roof.png" alt="" width={14} height={14} className="object-contain" />}
open={renameOpen} label={
onOpenChange={setRenameOpen} <InlineRenameInput
defaultName={defaultName} node={node}
> isEditing={isEditing}
<TreeNodeWrapper onStopEditing={() => setIsEditing(false)}
icon={<Image src="/icons/roof.png" alt="" width={14} height={14} className="object-contain" />} onStartEditing={() => setIsEditing(true)}
label={node.name || defaultName} defaultName={defaultName}
depth={depth} />
hasChildren={false} }
expanded={false} depth={depth}
onToggle={() => {}} hasChildren={false}
onClick={handleClick} expanded={false}
onDoubleClick={handleDoubleClick} onToggle={() => {}}
onMouseEnter={handleMouseEnter} onClick={handleClick}
onMouseLeave={handleMouseLeave} onDoubleClick={handleDoubleClick}
isSelected={isSelected} onMouseEnter={handleMouseEnter}
isHovered={isHovered} onMouseLeave={handleMouseLeave}
isVisible={node.visible !== false} isSelected={isSelected}
actions={<TreeNodeActions node={node} />} isHovered={isHovered}
/> isVisible={node.visible !== false}
</RenamePopover> actions={<TreeNodeActions node={node} />}
/>
); );
} }
@@ -2,7 +2,7 @@ import { SlabNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from "@pascal-app/viewer";
import Image from "next/image"; import Image from "next/image";
import { useState } from "react"; import { useState } from "react";
import { RenamePopover } from "./rename-popover"; import { InlineRenameInput } from "./inline-rename-input";
import { TreeNodeWrapper } from "./tree-node"; import { TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions"; import { TreeNodeActions } from "./tree-node-actions";
@@ -12,7 +12,7 @@ interface SlabTreeNodeProps {
} }
export function SlabTreeNode({ node, depth }: 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 isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
const isHovered = useViewer((state) => state.hoveredId === node.id); const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection); const setSelection = useViewer((state) => state.setSelection);
@@ -23,7 +23,7 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
}; };
const handleDoubleClick = () => { const handleDoubleClick = () => {
setRenameOpen(true); setIsEditing(true);
}; };
const handleMouseEnter = () => { const handleMouseEnter = () => {
@@ -39,29 +39,30 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
const defaultName = `Slab (${area}m²)`; const defaultName = `Slab (${area}m²)`;
return ( return (
<RenamePopover <TreeNodeWrapper
node={node} icon={<Image src="/icons/floor.png" alt="" width={14} height={14} className="object-contain" />}
open={renameOpen} label={
onOpenChange={setRenameOpen} <InlineRenameInput
defaultName={defaultName} node={node}
> isEditing={isEditing}
<TreeNodeWrapper onStopEditing={() => setIsEditing(false)}
icon={<Image src="/icons/floor.png" alt="" width={14} height={14} className="object-contain" />} onStartEditing={() => setIsEditing(true)}
label={node.name || defaultName} defaultName={defaultName}
depth={depth} />
hasChildren={false} }
expanded={false} depth={depth}
onToggle={() => {}} hasChildren={false}
onClick={handleClick} expanded={false}
onDoubleClick={handleDoubleClick} onToggle={() => {}}
onMouseEnter={handleMouseEnter} onClick={handleClick}
onMouseLeave={handleMouseLeave} onDoubleClick={handleDoubleClick}
isSelected={isSelected} onMouseEnter={handleMouseEnter}
isHovered={isHovered} onMouseLeave={handleMouseLeave}
isVisible={node.visible !== false} isSelected={isSelected}
actions={<TreeNodeActions node={node} />} isHovered={isHovered}
/> isVisible={node.visible !== false}
</RenamePopover> actions={<TreeNodeActions node={node} />}
/>
); );
} }
@@ -42,7 +42,7 @@ export function TreeNodeActions({ node }: TreeNodeActionsProps) {
return ( return (
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<button <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} onClick={toggleVisibility}
title={isVisible ? "Hide" : "Show"} title={isVisible ? "Hide" : "Show"}
> >
@@ -56,7 +56,7 @@ export function TreeNodeActions({ node }: TreeNodeActionsProps) {
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <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()} onClick={(e) => e.stopPropagation()}
title="Camera snapshot" title="Camera snapshot"
> >
@@ -1,6 +1,6 @@
import { AnyNodeId, useScene } from "@pascal-app/core"; import { AnyNodeId, useScene } from "@pascal-app/core";
import { ChevronDown, ChevronRight } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide-react";
import { forwardRef } from "react"; import { forwardRef, useEffect, useRef } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BuildingTreeNode } from "./building-tree-node"; import { BuildingTreeNode } from "./building-tree-node";
import { CeilingTreeNode } from "./ceiling-tree-node"; import { CeilingTreeNode } from "./ceiling-tree-node";
@@ -51,7 +51,7 @@ export function TreeNode({ nodeId, depth = 0 }: TreeNodeProps) {
interface TreeNodeWrapperProps { interface TreeNodeWrapperProps {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: React.ReactNode;
depth: number; depth: number;
hasChildren: boolean; hasChildren: boolean;
expanded: boolean; expanded: boolean;
@@ -88,19 +88,28 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
}, },
ref ref
) { ) {
const rowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isSelected && rowRef.current) {
rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [isSelected]);
return ( return (
<div ref={ref}> <div ref={ref}>
<div <div
ref={rowRef}
className={cn( 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 isSelected
? "text-primary-foreground bg-primary/80 hover:bg-primary/90" ? "bg-accent/50 text-foreground"
: isHovered : isHovered
? "bg-accent/70 text-foreground" ? "bg-accent/30 text-foreground"
: "text-muted-foreground hover:bg-accent/50", : "text-muted-foreground hover:bg-accent/30 hover:text-foreground",
!isVisible && "opacity-50" !isVisible && "opacity-50"
)} )}
style={{ paddingLeft: depth * 12 + 4 }} style={{ paddingLeft: depth * 12 + 12, paddingRight: 12 }}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
> >
@@ -124,10 +133,15 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
onClick={onClick} onClick={onClick}
onDoubleClick={onDoubleClick} 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} {icon}
</span> </span>
<span className="truncate">{label}</span> <div className="flex-1 min-w-0 truncate">
{label}
</div>
</div> </div>
{actions && ( {actions && (
<div className="opacity-0 group-hover/row:opacity-100 pr-1"> <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 { useViewer } from "@pascal-app/viewer";
import Image from "next/image"; import Image from "next/image";
import { useState } from "react"; import { useState } from "react";
import { RenamePopover } from "./rename-popover"; import { InlineRenameInput } from "./inline-rename-input";
import { TreeNode, TreeNodeWrapper } from "./tree-node"; import { TreeNode, TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions"; import { TreeNodeActions } from "./tree-node-actions";
@@ -13,7 +13,7 @@ interface WallTreeNodeProps {
export function WallTreeNode({ node, depth }: WallTreeNodeProps) { export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [renameOpen, setRenameOpen] = useState(false); const [isEditing, setIsEditing] = useState(false);
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id)); const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
const isHovered = useViewer((state) => state.hoveredId === node.id); const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection); const setSelection = useViewer((state) => state.setSelection);
@@ -24,7 +24,7 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
}; };
const handleDoubleClick = () => { const handleDoubleClick = () => {
setRenameOpen(true); setIsEditing(true);
}; };
const handleMouseEnter = () => { const handleMouseEnter = () => {
@@ -43,32 +43,33 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
const defaultName = `Wall (${wallLength}m/${node.height || 2.5}m)`; const defaultName = `Wall (${wallLength}m/${node.height || 2.5}m)`;
return ( return (
<RenamePopover <TreeNodeWrapper
node={node} icon={<Image src="/icons/wall.png" alt="" width={14} height={14} className="object-contain" />}
open={renameOpen} label={
onOpenChange={setRenameOpen} <InlineRenameInput
defaultName={defaultName} node={node}
isEditing={isEditing}
onStopEditing={() => setIsEditing(false)}
onStartEditing={() => setIsEditing(true)}
defaultName={defaultName}
/>
}
depth={depth}
hasChildren={node.children.length > 0}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
isVisible={node.visible !== false}
actions={<TreeNodeActions node={node} />}
> >
<TreeNodeWrapper {node.children.map((childId) => (
icon={<Image src="/icons/wall.png" alt="" width={14} height={14} className="object-contain" />} <TreeNode key={childId} nodeId={childId} depth={depth + 1} />
label={node.name || defaultName} ))}
depth={depth} </TreeNodeWrapper>
hasChildren={node.children.length > 0}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
isVisible={node.visible !== false}
actions={<TreeNodeActions node={node} />}
>
{node.children.map((childId) => (
<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 { useViewer } from "@pascal-app/viewer"
import Image from "next/image" import Image from "next/image"
import { useState } from "react" import { useState } from "react"
import { RenamePopover } from "./rename-popover" import { InlineRenameInput } from "./inline-rename-input"
import { TreeNodeWrapper } from "./tree-node" import { TreeNodeWrapper } from "./tree-node"
import { TreeNodeActions } from "./tree-node-actions" import { TreeNodeActions } from "./tree-node-actions"
@@ -14,7 +14,7 @@ interface WindowTreeNodeProps {
} }
export function WindowTreeNode({ node, depth }: 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 isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id))
const isHovered = useViewer((state) => state.hoveredId === node.id) const isHovered = useViewer((state) => state.hoveredId === node.id)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
@@ -23,28 +23,29 @@ export function WindowTreeNode({ node, depth }: WindowTreeNodeProps) {
const defaultName = `Window (${node.width}×${node.height}m)` const defaultName = `Window (${node.width}×${node.height}m)`
return ( return (
<RenamePopover <TreeNodeWrapper
node={node} icon={<Image src="/icons/window.png" alt="" width={14} height={14} className="object-contain" />}
open={renameOpen} label={
onOpenChange={setRenameOpen} <InlineRenameInput
defaultName={defaultName} node={node}
> isEditing={isEditing}
<TreeNodeWrapper onStopEditing={() => setIsEditing(false)}
icon={<Image src="/icons/window.png" alt="" width={14} height={14} className="object-contain" />} onStartEditing={() => setIsEditing(true)}
label={node.name || defaultName} defaultName={defaultName}
depth={depth} />
hasChildren={false} }
expanded={false} depth={depth}
onToggle={() => {}} hasChildren={false}
onClick={() => setSelection({ selectedIds: [node.id] })} expanded={false}
onDoubleClick={() => setRenameOpen(true)} onToggle={() => {}}
onMouseEnter={() => setHoveredId(node.id)} onClick={() => setSelection({ selectedIds: [node.id] })}
onMouseLeave={() => setHoveredId(null)} onDoubleClick={() => setIsEditing(true)}
isSelected={isSelected} onMouseEnter={() => setHoveredId(node.id)}
isHovered={isHovered} onMouseLeave={() => setHoveredId(null)}
isVisible={node.visible !== false} isSelected={isSelected}
actions={<TreeNodeActions node={node} />} isHovered={isHovered}
/> isVisible={node.visible !== false}
</RenamePopover> actions={<TreeNodeActions node={node} />}
/>
) )
} }
@@ -1,7 +1,7 @@
import { ZoneNode } from "@pascal-app/core"; import { ZoneNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from "@pascal-app/viewer";
import { useState } from "react"; import { useState } from "react";
import { RenamePopover } from "./rename-popover"; import { InlineRenameInput } from "./inline-rename-input";
import { TreeNodeWrapper } from "./tree-node"; import { TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions"; import { TreeNodeActions } from "./tree-node-actions";
@@ -11,7 +11,7 @@ interface ZoneTreeNodeProps {
} }
export function ZoneTreeNode({ node, depth }: 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 isSelected = useViewer((state) => state.selection.zoneId === node.id);
const isHovered = useViewer((state) => state.hoveredId === node.id); const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection); const setSelection = useViewer((state) => state.setSelection);
@@ -22,7 +22,7 @@ export function ZoneTreeNode({ node, depth }: ZoneTreeNodeProps) {
}; };
const handleDoubleClick = () => { const handleDoubleClick = () => {
setRenameOpen(true); setIsEditing(true);
}; };
const handleMouseEnter = () => { const handleMouseEnter = () => {
@@ -38,33 +38,34 @@ export function ZoneTreeNode({ node, depth }: ZoneTreeNodeProps) {
const defaultName = `Zone (${area}m²)`; const defaultName = `Zone (${area}m²)`;
return ( return (
<RenamePopover <TreeNodeWrapper
node={node} icon={
open={renameOpen} <div
onOpenChange={setRenameOpen} className="w-3 h-3 rounded-sm border border-border/50"
defaultName={defaultName} style={{ backgroundColor: node.color }}
> />
<TreeNodeWrapper }
icon={ label={
<div <InlineRenameInput
className="w-3 h-3 rounded-sm border border-border/50" node={node}
style={{ backgroundColor: node.color }} isEditing={isEditing}
/> onStopEditing={() => setIsEditing(false)}
} onStartEditing={() => setIsEditing(true)}
label={node.name || defaultName} defaultName={defaultName}
depth={depth} />
hasChildren={false} }
expanded={false} depth={depth}
onToggle={() => {}} hasChildren={false}
onClick={handleClick} expanded={false}
onDoubleClick={handleDoubleClick} onToggle={() => {}}
onMouseEnter={handleMouseEnter} onClick={handleClick}
onMouseLeave={handleMouseLeave} onDoubleClick={handleDoubleClick}
isSelected={isSelected} onMouseEnter={handleMouseEnter}
isHovered={isHovered} onMouseLeave={handleMouseLeave}
actions={<TreeNodeActions node={node} />} isSelected={isSelected}
/> isHovered={isHovered}
</RenamePopover> actions={<TreeNodeActions node={node} />}
/>
); );
} }
@@ -50,10 +50,10 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
return ( return (
<div <div
className={cn( 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 isSelected
? "text-primary-foreground bg-primary/80 hover:bg-primary/90" ? "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"
: "text-muted-foreground hover:bg-accent/50" : "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} onClick={handleClick}
> >
@@ -91,7 +91,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
<Popover open={cameraPopoverOpen} onOpenChange={setCameraPopoverOpen}> <Popover open={cameraPopoverOpen} onOpenChange={setCameraPopoverOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <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()} onClick={(e) => e.stopPropagation()}
title="Camera snapshot" title="Camera snapshot"
> >
@@ -149,7 +149,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<button <button
className="opacity-0 group-hover/row:opacity-100 w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20" className="opacity-0 group-hover/row:opacity-100 w-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} onClick={handleDelete}
> >
<Trash2 className="w-3 h-3" /> <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;
+80 -24
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 { cn } from "@/lib/utils";
import * as SliderPrimitive from '@radix-ui/react-slider'
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< type SliderProps = React.ComponentProps<typeof SliderPrimitive.Root> &
React.ElementRef<typeof SliderPrimitive.Root>, VariantProps<typeof sliderVariants>;
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
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>
<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.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider } function Slider({ variant, className, ...props }: SliderProps) {
return (
<SliderPrimitive.Root
data-slot="slider"
className={cn(sliderVariants({ variant }), className)}
{...props}
>
<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
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>
);
}
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", "type": "module",
"private": true, "private": true,
"scripts": { "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", "build": "next build",
"start": "next start", "start": "next start",
"lint": "biome lint", "lint": "biome lint",
"check-types": "next typegen && tsc --noEmit" "check-types": "next typegen && tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@number-flow/react": "^0.5.14",
"@pascal-app/auth": "*", "@pascal-app/auth": "*",
"@pascal-app/core": "*", "@pascal-app/core": "*",
"@pascal-app/db": "*", "@pascal-app/db": "*",
+13 -1
View File
@@ -7,6 +7,7 @@
"dependencies": { "dependencies": {
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0", "@react-three/fiber": "^9.5.0",
"portless": "^0.4.2",
"three": "^0.183.0", "three": "^0.183.0",
"three-bvh-csg": "^0.0.17", "three-bvh-csg": "^0.0.17",
"three-mesh-bvh": "^0.9.8", "three-mesh-bvh": "^0.9.8",
@@ -24,6 +25,7 @@
"name": "web", "name": "web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@number-flow/react": "^0.5.14",
"@pascal-app/auth": "*", "@pascal-app/auth": "*",
"@pascal-app/core": "*", "@pascal-app/core": "*",
"@pascal-app/db": "*", "@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=="], "@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/auth": ["@pascal-app/auth@workspace:packages/auth"],
"@pascal-app/core": ["@pascal-app/core@workspace:packages/core"], "@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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -1300,6 +1308,8 @@
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "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=="], "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=="], "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=="], "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=="], "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=="], "gel/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
+1
View File
@@ -29,6 +29,7 @@
"dependencies": { "dependencies": {
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0", "@react-three/fiber": "^9.5.0",
"portless": "^0.4.2",
"three": "^0.183.0", "three": "^0.183.0",
"three-bvh-csg": "^0.0.17", "three-bvh-csg": "^0.0.17",
"three-mesh-bvh": "^0.9.8", "three-mesh-bvh": "^0.9.8",