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:
co-authored by
Claude Opus 4.6
parent
94e5f37983
commit
48d5091bb3
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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">°</span>
|
<span className="text-muted-foreground text-xs shrink-0">°</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() {
|
|||||||
−45
|
−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"
|
||||||
|
|||||||
@@ -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">°</span>
|
<span className="text-muted-foreground text-xs shrink-0">°</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)) {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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": "*",
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user