remove community
This commit is contained in:
@@ -40,6 +40,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"howler": "^2.2.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mitt": "^3.0.1",
|
||||
"motion": "^12.34.3",
|
||||
"nanoid": "^5.1.6",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
@@ -49,14 +50,11 @@
|
||||
"devDependencies": {
|
||||
"@pascal-app/core": "*",
|
||||
"@pascal-app/viewer": "*",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@repo/typescript-config": "*",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"@types/three": "^0.183.1",
|
||||
"react": "^19.2.4",
|
||||
"three": "^0.183.1",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +61,7 @@ export const CustomCameraControls = () => {
|
||||
: CameraControlsImpl.ACTION.DOLLY
|
||||
|
||||
return {
|
||||
left: isPreviewMode
|
||||
? CameraControlsImpl.ACTION.SCREEN_PAN
|
||||
: CameraControlsImpl.ACTION.NONE,
|
||||
left: isPreviewMode ? CameraControlsImpl.ACTION.SCREEN_PAN : CameraControlsImpl.ACTION.NONE,
|
||||
middle: CameraControlsImpl.ACTION.SCREEN_PAN,
|
||||
right: CameraControlsImpl.ACTION.ROTATE,
|
||||
wheel: wheelAction,
|
||||
@@ -159,7 +157,7 @@ export const CustomCameraControls = () => {
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPreviewMode || !controls.current) return
|
||||
if (!(isPreviewMode && controls.current)) return
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null
|
||||
@@ -176,8 +174,12 @@ export const CustomCameraControls = () => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!controls.current) return
|
||||
controls.current.setLookAt(
|
||||
position[0], position[1], position[2],
|
||||
target[0], target[1], target[2],
|
||||
position[0],
|
||||
position[1],
|
||||
position[2],
|
||||
target[0],
|
||||
target[1],
|
||||
target[2],
|
||||
true,
|
||||
)
|
||||
})
|
||||
@@ -231,7 +233,7 @@ export const CustomCameraControls = () => {
|
||||
if (!controls.current) return
|
||||
|
||||
const node = useScene.getState().nodes[nodeId]
|
||||
if (!node || !node.camera) return
|
||||
if (!(node && node.camera)) return
|
||||
const { position, target } = node.camera
|
||||
|
||||
controls.current.setLookAt(
|
||||
@@ -311,11 +313,11 @@ export const CustomCameraControls = () => {
|
||||
maxPolarAngle={Math.PI / 2 - 0.1}
|
||||
minDistance={10}
|
||||
minPolarAngle={0}
|
||||
ref={controls}
|
||||
mouseButtons={mouseButtons}
|
||||
onTransitionStart={onTransitionStart}
|
||||
onRest={onRest}
|
||||
onSleep={onRest}
|
||||
onTransitionStart={onTransitionStart}
|
||||
ref={controls}
|
||||
restThreshold={0.01}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ export function ExportManager() {
|
||||
console.error('Export error:', error)
|
||||
reject(error)
|
||||
},
|
||||
{ binary: true }
|
||||
{ binary: true },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function FloatingActionMenu() {
|
||||
const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false
|
||||
|
||||
useFrame(() => {
|
||||
if (!selectedId || !isValidType || !groupRef.current) return
|
||||
if (!(selectedId && isValidType && groupRef.current)) return
|
||||
|
||||
const obj = sceneRegistry.nodes.get(selectedId)
|
||||
if (obj) {
|
||||
@@ -65,7 +65,7 @@ export function FloatingActionMenu() {
|
||||
const handleDuplicate = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!node || !node.parentId) return
|
||||
if (!(node && node.parentId)) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
@@ -103,7 +103,7 @@ export function FloatingActionMenu() {
|
||||
const handleDelete = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!selectedId || !node) return
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:item-delete')
|
||||
deleteNode(selectedId as AnyNodeId)
|
||||
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
@@ -112,43 +112,43 @@ export function FloatingActionMenu() {
|
||||
[selectedId, node, deleteNode, setSelection],
|
||||
)
|
||||
|
||||
if (!selectedId || !node || !isValidType) return null
|
||||
if (!(selectedId && node && isValidType)) return null
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<Html
|
||||
center
|
||||
zIndexRange={[100, 0]}
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 p-1 rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md"
|
||||
className="flex items-center gap-1 rounded-lg border border-border bg-background/95 p-1 shadow-xl backdrop-blur-md"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={handleMove}
|
||||
className="p-1.5 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors tooltip-trigger"
|
||||
title="Move"
|
||||
>
|
||||
<Move className="w-4 h-4" />
|
||||
<Move className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={handleDuplicate}
|
||||
className="p-1.5 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors tooltip-trigger"
|
||||
title="Duplicate"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={handleDelete}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors tooltip-trigger"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Html>
|
||||
|
||||
@@ -32,11 +32,11 @@ export const Grid = ({
|
||||
revealRadius?: number
|
||||
}) => {
|
||||
const theme = useViewer((state) => state.theme)
|
||||
|
||||
|
||||
// Use slightly lighter colors for dark mode grid to make it apparent
|
||||
const effectiveCellColor = theme === 'dark' ? '#555566' : cellColor
|
||||
const effectiveSectionColor = theme === 'dark' ? '#666677' : sectionColor
|
||||
|
||||
|
||||
const cursorPositionRef = useRef(new Vector2(0, 0))
|
||||
|
||||
const material = useMemo(() => {
|
||||
@@ -90,7 +90,7 @@ export const Grid = ({
|
||||
|
||||
// Baseline alpha: small amount of opacity everywhere the grid exists
|
||||
const baseAlpha = float(0.4) // Subtle global visibility
|
||||
|
||||
|
||||
// Combined alpha with cursor fade and baseline minimum
|
||||
const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlpha))
|
||||
const finalAlpha = mix(alpha.mul(0.75), alpha, g2)
|
||||
@@ -102,15 +102,15 @@ export const Grid = ({
|
||||
depthWrite: false,
|
||||
})
|
||||
}, [
|
||||
cellSize,
|
||||
cellThickness,
|
||||
effectiveCellColor,
|
||||
sectionSize,
|
||||
sectionThickness,
|
||||
effectiveSectionColor,
|
||||
fadeDistance,
|
||||
fadeStrength,
|
||||
revealRadius
|
||||
cellSize,
|
||||
cellThickness,
|
||||
effectiveCellColor,
|
||||
sectionSize,
|
||||
sectionThickness,
|
||||
effectiveSectionColor,
|
||||
fadeDistance,
|
||||
fadeStrength,
|
||||
revealRadius,
|
||||
])
|
||||
|
||||
const gridRef = useRef<Mesh>(null!)
|
||||
@@ -148,7 +148,13 @@ export const Grid = ({
|
||||
const showGrid = useViewer((state) => state.showGrid)
|
||||
|
||||
return (
|
||||
<mesh rotation-x={-Math.PI / 2} material={material} ref={gridRef} visible={showGrid} layers={EDITOR_LAYER}>
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
material={material}
|
||||
ref={gridRef}
|
||||
rotation-x={-Math.PI / 2}
|
||||
visible={showGrid}
|
||||
>
|
||||
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
|
||||
</mesh>
|
||||
)
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
|
||||
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { useAutoSave, type SaveStatus } from '../../hooks/use-auto-save'
|
||||
import { applySceneGraphToEditor, loadSceneFromLocalStorage, type SceneGraph } from '../../lib/scene'
|
||||
import { useKeyboard } from '../../hooks/use-keyboard'
|
||||
import { initSFXBus } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { ViewerOverlay } from '../../components/viewer-overlay'
|
||||
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
||||
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
|
||||
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
|
||||
import { useKeyboard } from '../../hooks/use-keyboard'
|
||||
import {
|
||||
applySceneGraphToEditor,
|
||||
loadSceneFromLocalStorage,
|
||||
type SceneGraph,
|
||||
} from '../../lib/scene'
|
||||
import { initSFXBus } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
||||
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||
@@ -23,7 +28,6 @@ import { SceneLoader } from '../ui/scene-loader'
|
||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||
import type { SettingsPanelProps } from '../ui/sidebar/panels/settings-panel'
|
||||
import type { SitePanelProps } from '../ui/sidebar/panels/site-panel'
|
||||
import { PresetsProvider, type PresetsAdapter } from '../../contexts/presets-context'
|
||||
import { CustomCameraControls } from './custom-camera-controls'
|
||||
import { ExportManager } from './export-manager'
|
||||
import { FloatingActionMenu } from './floating-action-menu'
|
||||
@@ -97,20 +101,20 @@ function EditorSceneCrashFallback() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-background/95 p-4 text-foreground">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
|
||||
<h2 className="text-lg font-semibold">The editor scene failed to render</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<h2 className="font-semibold text-lg">The editor scene failed to render</h2>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
You can retry the scene or return home without reloading the whole app shell.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
className="rounded-md border border-border bg-accent px-3 py-2 text-sm font-medium hover:bg-accent/80"
|
||||
className="rounded-md border border-border bg-accent px-3 py-2 font-medium text-sm hover:bg-accent/80"
|
||||
onClick={() => window.location.reload()}
|
||||
type="button"
|
||||
>
|
||||
Reload editor
|
||||
</button>
|
||||
<a
|
||||
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
|
||||
className="rounded-md border border-border bg-background px-3 py-2 font-medium text-sm hover:bg-accent/40"
|
||||
href="/"
|
||||
>
|
||||
Back to home
|
||||
@@ -175,7 +179,9 @@ export default function Editor({
|
||||
|
||||
load()
|
||||
|
||||
return () => { cancelled = true }
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [onLoad, isLoadingSceneRef])
|
||||
|
||||
// Apply preview scene when version preview mode changes
|
||||
@@ -196,45 +202,46 @@ export default function Editor({
|
||||
|
||||
return (
|
||||
<PresetsProvider adapter={presetsAdapter}>
|
||||
<div className="w-full h-full dark text-foreground">
|
||||
{showLoader && <SceneLoader />}
|
||||
<div className="dark h-full w-full text-foreground">
|
||||
{showLoader && <SceneLoader />}
|
||||
|
||||
{isPreviewMode ? (
|
||||
<ViewerOverlay
|
||||
onBack={() => useEditor.getState().setPreviewMode(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ActionMenu />
|
||||
<PanelManager />
|
||||
<HelperManager />
|
||||
{isPreviewMode ? (
|
||||
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
||||
) : (
|
||||
<>
|
||||
<ActionMenu />
|
||||
<PanelManager />
|
||||
<HelperManager />
|
||||
|
||||
<SidebarProvider className="fixed z-20">
|
||||
<AppSidebar appMenuButton={appMenuButton} sidebarTop={sidebarTop} settingsPanelProps={settingsPanelProps} sitePanelProps={sitePanelProps} />
|
||||
</SidebarProvider>
|
||||
</>
|
||||
)}
|
||||
<SidebarProvider className="fixed z-20">
|
||||
<AppSidebar
|
||||
appMenuButton={appMenuButton}
|
||||
settingsPanelProps={settingsPanelProps}
|
||||
sidebarTop={sidebarTop}
|
||||
sitePanelProps={sitePanelProps}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
|
||||
<Viewer selectionManager={isPreviewMode ? 'default' : 'custom'}>
|
||||
{!isPreviewMode && <SelectionManager />}
|
||||
{!isPreviewMode && <FloatingActionMenu />}
|
||||
<ExportManager />
|
||||
{isPreviewMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||
<CeilingSystem />
|
||||
{!isPreviewMode && (
|
||||
<Grid cellColor="#aaa" sectionColor="#ccc" fadeDistance={500} />
|
||||
)}
|
||||
{!isPreviewMode && <ToolManager />}
|
||||
<CustomCameraControls />
|
||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||
<PresetThumbnailGenerator />
|
||||
{!isPreviewMode && <SiteEdgeLabels />}
|
||||
{isPreviewMode && <InteractiveSystem />}
|
||||
</Viewer>
|
||||
{!isPreviewMode && <ZoneLabelEditorSystem />}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
|
||||
<Viewer selectionManager={isPreviewMode ? 'default' : 'custom'}>
|
||||
{!isPreviewMode && <SelectionManager />}
|
||||
{!isPreviewMode && <FloatingActionMenu />}
|
||||
<ExportManager />
|
||||
{isPreviewMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||
<CeilingSystem />
|
||||
{!isPreviewMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
|
||||
{!isPreviewMode && <ToolManager />}
|
||||
<CustomCameraControls />
|
||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||
<PresetThumbnailGenerator />
|
||||
{!isPreviewMode && <SiteEdgeLabels />}
|
||||
{isPreviewMode && <InteractiveSystem />}
|
||||
</Viewer>
|
||||
{!isPreviewMode && <ZoneLabelEditorSystem />}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</PresetsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,10 @@ export const PresetThumbnailGenerator = () => {
|
||||
|
||||
const clones: THREE.Object3D[] = []
|
||||
target.traverse((obj) => {
|
||||
if (!(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)) return
|
||||
if (
|
||||
!(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)
|
||||
)
|
||||
return
|
||||
const c = obj.clone(false) // shallow clone: copies geometry, material, visible — no children
|
||||
relMatrix.multiplyMatrices(targetInverse, obj.matrixWorld)
|
||||
relMatrix.decompose(c.position, c.quaternion, c.scale)
|
||||
@@ -72,7 +75,10 @@ export const PresetThumbnailGenerator = () => {
|
||||
const snapshot = new Map<THREE.Object3D, boolean>()
|
||||
scene.traverse((obj) => {
|
||||
if (cloneSet.has(obj)) return
|
||||
if (!(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)) return
|
||||
if (
|
||||
!(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)
|
||||
)
|
||||
return
|
||||
snapshot.set(obj, obj.visible)
|
||||
obj.visible = false
|
||||
})
|
||||
|
||||
@@ -7,443 +7,483 @@ import {
|
||||
resolveLevelId,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from "@pascal-app/core";
|
||||
} from '@pascal-app/core'
|
||||
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { useEffect, useRef } from "react";
|
||||
import useEditor from "./../../store/use-editor";
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import useEditor from './../../store/use-editor'
|
||||
|
||||
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
||||
const currentLevelId = useViewer.getState().selection.levelId;
|
||||
if (!currentLevelId) return true; // No level selected, allow all
|
||||
const nodeLevelId = resolveLevelId(node, useScene.getState().nodes);
|
||||
return nodeLevelId === currentLevelId;
|
||||
};
|
||||
|
||||
type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling' | 'roof' | 'window' | 'door';
|
||||
|
||||
type ModifierKeys = {
|
||||
meta: boolean;
|
||||
ctrl: boolean;
|
||||
};
|
||||
|
||||
interface SelectionStrategy {
|
||||
types: SelectableNodeType[];
|
||||
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void;
|
||||
handleDeselect: () => void;
|
||||
isValid: (node: AnyNode) => boolean;
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
if (!currentLevelId) return true // No level selected, allow all
|
||||
const nodeLevelId = resolveLevelId(node, useScene.getState().nodes)
|
||||
return nodeLevelId === currentLevelId
|
||||
}
|
||||
|
||||
export const resolveBuildingId = (levelId: string, nodes: Record<string, AnyNode>): string | null => {
|
||||
const level = nodes[levelId];
|
||||
if (!level) return null;
|
||||
if (level.parentId && nodes[level.parentId]?.type === "building") {
|
||||
return level.parentId;
|
||||
type SelectableNodeType =
|
||||
| 'wall'
|
||||
| 'item'
|
||||
| 'building'
|
||||
| 'zone'
|
||||
| 'slab'
|
||||
| 'ceiling'
|
||||
| 'roof'
|
||||
| 'window'
|
||||
| 'door'
|
||||
|
||||
type ModifierKeys = {
|
||||
meta: boolean
|
||||
ctrl: boolean
|
||||
}
|
||||
|
||||
interface SelectionStrategy {
|
||||
types: SelectableNodeType[]
|
||||
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
|
||||
handleDeselect: () => void
|
||||
isValid: (node: AnyNode) => boolean
|
||||
}
|
||||
|
||||
export const resolveBuildingId = (
|
||||
levelId: string,
|
||||
nodes: Record<string, AnyNode>,
|
||||
): string | null => {
|
||||
const level = nodes[levelId]
|
||||
if (!level) return null
|
||||
if (level.parentId && nodes[level.parentId]?.type === 'building') {
|
||||
return level.parentId
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
const computeNextIds = (
|
||||
node: AnyNode,
|
||||
selectedIds: string[],
|
||||
event?: any,
|
||||
modifierKeys?: ModifierKeys
|
||||
modifierKeys?: ModifierKeys,
|
||||
): string[] => {
|
||||
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta || false;
|
||||
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl || false;
|
||||
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta
|
||||
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl
|
||||
|
||||
console.log("computeNextIds:", {
|
||||
console.log('computeNextIds:', {
|
||||
nodeId: node.id,
|
||||
selectedIds,
|
||||
isMeta,
|
||||
isCtrl,
|
||||
eventMeta: event?.metaKey,
|
||||
nativeMeta: event?.nativeEvent?.metaKey,
|
||||
modMeta: modifierKeys?.meta
|
||||
});
|
||||
modMeta: modifierKeys?.meta,
|
||||
})
|
||||
|
||||
if (isMeta || isCtrl) {
|
||||
if (selectedIds.includes(node.id)) {
|
||||
return selectedIds.filter((id) => id !== node.id);
|
||||
} else {
|
||||
return [...selectedIds, node.id];
|
||||
return selectedIds.filter((id) => id !== node.id)
|
||||
}
|
||||
return [...selectedIds, node.id]
|
||||
}
|
||||
|
||||
// Not holding modifiers: select only this node
|
||||
return [node.id];
|
||||
};
|
||||
return [node.id]
|
||||
}
|
||||
|
||||
const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
||||
site: {
|
||||
types: ["building"],
|
||||
types: ['building'],
|
||||
handleSelect: (node) => {
|
||||
useViewer
|
||||
.getState()
|
||||
.setSelection({ buildingId: (node as BuildingNode).id });
|
||||
useViewer.getState().setSelection({ buildingId: (node as BuildingNode).id })
|
||||
},
|
||||
handleDeselect: () => {
|
||||
useViewer.getState().setSelection({ buildingId: null });
|
||||
useViewer.getState().setSelection({ buildingId: null })
|
||||
},
|
||||
isValid: (node) => node.type === "building",
|
||||
isValid: (node) => node.type === 'building',
|
||||
},
|
||||
|
||||
structure: {
|
||||
types: ["wall", "item", "zone", "slab", "ceiling", "roof", "window", "door"],
|
||||
types: ['wall', 'item', 'zone', 'slab', 'ceiling', 'roof', 'window', 'door'],
|
||||
handleSelect: (node, nativeEvent, modifierKeys) => {
|
||||
const { selection, setSelection } = useViewer.getState();
|
||||
const nodes = useScene.getState().nodes;
|
||||
const nodeLevelId = resolveLevelId(node, nodes);
|
||||
const buildingId = resolveBuildingId(nodeLevelId, nodes);
|
||||
const { selection, setSelection } = useViewer.getState()
|
||||
const nodes = useScene.getState().nodes
|
||||
const nodeLevelId = resolveLevelId(node, nodes)
|
||||
const buildingId = resolveBuildingId(nodeLevelId, nodes)
|
||||
|
||||
const updates: any = {};
|
||||
if (nodeLevelId !== "default" && nodeLevelId !== selection.levelId) {
|
||||
updates.levelId = nodeLevelId;
|
||||
const updates: any = {}
|
||||
if (nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
|
||||
updates.levelId = nodeLevelId
|
||||
}
|
||||
if (buildingId && buildingId !== selection.buildingId) {
|
||||
updates.buildingId = buildingId;
|
||||
updates.buildingId = buildingId
|
||||
}
|
||||
|
||||
if (node.type === 'zone') {
|
||||
updates.zoneId = node.id;
|
||||
updates.zoneId = node.id
|
||||
// Don't reset selectedIds in structure phase for zone, but if we changed level, it might reset them via hierarchy guard.
|
||||
// Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId.
|
||||
setSelection(updates);
|
||||
setSelection(updates)
|
||||
} else {
|
||||
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys);
|
||||
setSelection(updates);
|
||||
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys)
|
||||
setSelection(updates)
|
||||
}
|
||||
},
|
||||
handleDeselect: () => {
|
||||
const structureLayer = useEditor.getState().structureLayer;
|
||||
if (structureLayer === "zones") {
|
||||
useViewer.getState().setSelection({ zoneId: null });
|
||||
const structureLayer = useEditor.getState().structureLayer
|
||||
if (structureLayer === 'zones') {
|
||||
useViewer.getState().setSelection({ zoneId: null })
|
||||
} else {
|
||||
useViewer.getState().setSelection({ selectedIds: [] });
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
}
|
||||
},
|
||||
isValid: (node) => {
|
||||
if (!isNodeInCurrentLevel(node)) return false;
|
||||
const structureLayer = useEditor.getState().structureLayer;
|
||||
if (structureLayer === "zones") {
|
||||
if (node.type === "zone") return true;
|
||||
return false;
|
||||
} else {
|
||||
if (node.type === "wall" || node.type === "slab" || node.type === "ceiling" || node.type === "roof") return true;
|
||||
if (node.type === "item") {
|
||||
return (
|
||||
(node as ItemNode).asset.category === "door" ||
|
||||
(node as ItemNode).asset.category === "window"
|
||||
);
|
||||
}
|
||||
if (node.type === "window" || node.type === "door") return true;
|
||||
|
||||
return false;
|
||||
if (!isNodeInCurrentLevel(node)) return false
|
||||
const structureLayer = useEditor.getState().structureLayer
|
||||
if (structureLayer === 'zones') {
|
||||
if (node.type === 'zone') return true
|
||||
return false
|
||||
}
|
||||
if (
|
||||
node.type === 'wall' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'roof'
|
||||
)
|
||||
return true
|
||||
if (node.type === 'item') {
|
||||
return (
|
||||
(node as ItemNode).asset.category === 'door' ||
|
||||
(node as ItemNode).asset.category === 'window'
|
||||
)
|
||||
}
|
||||
if (node.type === 'window' || node.type === 'door') return true
|
||||
|
||||
return false
|
||||
},
|
||||
},
|
||||
|
||||
furnish: {
|
||||
types: ["item"],
|
||||
types: ['item'],
|
||||
handleSelect: (node, nativeEvent, modifierKeys) => {
|
||||
const { selection, setSelection } = useViewer.getState();
|
||||
const nodes = useScene.getState().nodes;
|
||||
const nodeLevelId = resolveLevelId(node, nodes);
|
||||
const buildingId = resolveBuildingId(nodeLevelId, nodes);
|
||||
const { selection, setSelection } = useViewer.getState()
|
||||
const nodes = useScene.getState().nodes
|
||||
const nodeLevelId = resolveLevelId(node, nodes)
|
||||
const buildingId = resolveBuildingId(nodeLevelId, nodes)
|
||||
|
||||
const updates: any = {};
|
||||
if (nodeLevelId !== "default" && nodeLevelId !== selection.levelId) {
|
||||
updates.levelId = nodeLevelId;
|
||||
const updates: any = {}
|
||||
if (nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
|
||||
updates.levelId = nodeLevelId
|
||||
}
|
||||
if (buildingId && buildingId !== selection.buildingId) {
|
||||
updates.buildingId = buildingId;
|
||||
updates.buildingId = buildingId
|
||||
}
|
||||
|
||||
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys);
|
||||
setSelection(updates);
|
||||
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys)
|
||||
setSelection(updates)
|
||||
},
|
||||
handleDeselect: () => {
|
||||
useViewer.getState().setSelection({ selectedIds: [] });
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
},
|
||||
isValid: (node) => {
|
||||
if (!isNodeInCurrentLevel(node)) return false;
|
||||
if (node.type !== "item") return false;
|
||||
const item = node as ItemNode;
|
||||
return item.asset.category !== "door" && item.asset.category !== "window";
|
||||
if (!isNodeInCurrentLevel(node)) return false
|
||||
if (node.type !== 'item') return false
|
||||
const item = node as ItemNode
|
||||
return item.asset.category !== 'door' && item.asset.category !== 'window'
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const SelectionManager = () => {
|
||||
const phase = useEditor((s) => s.phase);
|
||||
const mode = useEditor((s) => s.mode);
|
||||
const phase = useEditor((s) => s.phase)
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const modifierKeysRef = useRef<ModifierKeys>({
|
||||
meta: false,
|
||||
ctrl: false,
|
||||
});
|
||||
const clickHandledRef = useRef(false);
|
||||
})
|
||||
const clickHandledRef = useRef(false)
|
||||
|
||||
const movingNode = useEditor((s) => s.movingNode);
|
||||
const movingNode = useEditor((s) => s.movingNode)
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Meta") modifierKeysRef.current.meta = true;
|
||||
if (event.key === "Control") modifierKeysRef.current.ctrl = true;
|
||||
};
|
||||
if (event.key === 'Meta') modifierKeysRef.current.meta = true
|
||||
if (event.key === 'Control') modifierKeysRef.current.ctrl = true
|
||||
}
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === "Meta") modifierKeysRef.current.meta = false;
|
||||
if (event.key === "Control") modifierKeysRef.current.ctrl = false;
|
||||
};
|
||||
if (event.key === 'Meta') modifierKeysRef.current.meta = false
|
||||
if (event.key === 'Control') modifierKeysRef.current.ctrl = false
|
||||
}
|
||||
|
||||
const clearModifiers = () => {
|
||||
modifierKeysRef.current.meta = false;
|
||||
modifierKeysRef.current.ctrl = false;
|
||||
};
|
||||
modifierKeysRef.current.meta = false
|
||||
modifierKeysRef.current.ctrl = false
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
window.addEventListener("blur", clearModifiers);
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', clearModifiers)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
window.removeEventListener("blur", clearModifiers);
|
||||
};
|
||||
}, []);
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', clearModifiers)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "select") return;
|
||||
if (movingNode) return;
|
||||
if (mode !== 'select') return
|
||||
if (movingNode) return
|
||||
|
||||
const onClick = (event: NodeEvent) => {
|
||||
const node = event.node;
|
||||
let currentPhase = useEditor.getState().phase;
|
||||
let targetPhase = currentPhase;
|
||||
const node = event.node
|
||||
let currentPhase = useEditor.getState().phase
|
||||
let targetPhase = currentPhase
|
||||
|
||||
// Auto-switch between structure and furnish phases when clicking elements on the same level
|
||||
if (currentPhase === "structure" || currentPhase === "furnish") {
|
||||
if (currentPhase === 'structure' || currentPhase === 'furnish') {
|
||||
if (isNodeInCurrentLevel(node)) {
|
||||
if (
|
||||
node.type === "wall" ||
|
||||
node.type === "slab" ||
|
||||
node.type === "ceiling" ||
|
||||
node.type === "roof" ||
|
||||
node.type === "window" ||
|
||||
node.type === "door"
|
||||
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";
|
||||
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";
|
||||
targetPhase = 'furnish'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (targetPhase !== currentPhase) {
|
||||
useEditor.getState().setPhase(targetPhase);
|
||||
if (targetPhase === "structure" && useEditor.getState().structureLayer === "zones") {
|
||||
useEditor.getState().setStructureLayer("elements");
|
||||
useEditor.getState().setPhase(targetPhase)
|
||||
if (targetPhase === 'structure' && useEditor.getState().structureLayer === 'zones') {
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
}
|
||||
currentPhase = targetPhase;
|
||||
currentPhase = targetPhase
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeStrategy = SELECTION_STRATEGIES[currentPhase];
|
||||
const activeStrategy = SELECTION_STRATEGIES[currentPhase]
|
||||
if (activeStrategy?.isValid(node)) {
|
||||
event.stopPropagation();
|
||||
clickHandledRef.current = true;
|
||||
|
||||
console.log("[SelectionManager] Valid click on:", node.type, node.id, "Shift:", event.nativeEvent.shiftKey);
|
||||
activeStrategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current);
|
||||
event.stopPropagation()
|
||||
clickHandledRef.current = true
|
||||
|
||||
console.log(
|
||||
'[SelectionManager] Valid click on:',
|
||||
node.type,
|
||||
node.id,
|
||||
'Shift:',
|
||||
event.nativeEvent.shiftKey,
|
||||
)
|
||||
activeStrategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current)
|
||||
|
||||
// Reset the handled flag after a short delay to allow grid:click to be ignored
|
||||
setTimeout(() => {
|
||||
clickHandledRef.current = false;
|
||||
}, 50);
|
||||
clickHandledRef.current = false
|
||||
}, 50)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const allTypes = ["wall", "item", "building", "zone", "slab", "ceiling", "roof", "window", "door"];
|
||||
const allTypes = [
|
||||
'wall',
|
||||
'item',
|
||||
'building',
|
||||
'zone',
|
||||
'slab',
|
||||
'ceiling',
|
||||
'roof',
|
||||
'window',
|
||||
'door',
|
||||
]
|
||||
allTypes.forEach((type) => {
|
||||
emitter.on(`${type}:click` as any, onClick as any);
|
||||
});
|
||||
emitter.on(`${type}:click` as any, onClick as any)
|
||||
})
|
||||
|
||||
const onGridClick = () => {
|
||||
if (clickHandledRef.current) return;
|
||||
console.log("onGridClick triggered! Deselecting.");
|
||||
const activeStrategy = SELECTION_STRATEGIES[useEditor.getState().phase];
|
||||
if (activeStrategy) activeStrategy.handleDeselect();
|
||||
};
|
||||
emitter.on("grid:click", onGridClick);
|
||||
if (clickHandledRef.current) return
|
||||
console.log('onGridClick triggered! Deselecting.')
|
||||
const activeStrategy = SELECTION_STRATEGIES[useEditor.getState().phase]
|
||||
if (activeStrategy) activeStrategy.handleDeselect()
|
||||
}
|
||||
emitter.on('grid:click', onGridClick)
|
||||
|
||||
return () => {
|
||||
allTypes.forEach((type) => {
|
||||
emitter.off(`${type}:click` as any, onClick as any);
|
||||
});
|
||||
emitter.off("grid:click", onGridClick);
|
||||
};
|
||||
}, [mode, movingNode]);
|
||||
emitter.off(`${type}:click` as any, onClick as any)
|
||||
})
|
||||
emitter.off('grid:click', onGridClick)
|
||||
}
|
||||
}, [mode, movingNode])
|
||||
|
||||
// Global double-click handler for auto-switching phases and cross-phase hover
|
||||
useEffect(() => {
|
||||
if (mode !== "select") return;
|
||||
if (movingNode) return;
|
||||
if (mode !== 'select') return
|
||||
if (movingNode) return
|
||||
|
||||
const onEnter = (event: NodeEvent) => {
|
||||
const node = event.node;
|
||||
const currentPhase = useEditor.getState().phase;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
if (currentPhase === 'structure' || currentPhase === 'furnish') {
|
||||
if (!isNodeInCurrentLevel(node)) return
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
useViewer.setState({ hoveredId: node.id });
|
||||
};
|
||||
event.stopPropagation()
|
||||
useViewer.setState({ hoveredId: node.id })
|
||||
}
|
||||
|
||||
const onLeave = (event: NodeEvent) => {
|
||||
if (useViewer.getState().hoveredId === event.node.id) {
|
||||
useViewer.setState({ hoveredId: null });
|
||||
useViewer.setState({ hoveredId: null })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const onDoubleClick = (event: NodeEvent) => {
|
||||
const node = event.node;
|
||||
const currentPhase = useEditor.getState().phase;
|
||||
|
||||
let targetPhase: "site" | "structure" | "furnish" | null = null;
|
||||
const node = event.node
|
||||
const currentPhase = useEditor.getState().phase
|
||||
|
||||
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
|
||||
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";
|
||||
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"
|
||||
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";
|
||||
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";
|
||||
targetPhase = 'furnish'
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === "zone") {
|
||||
return;
|
||||
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");
|
||||
event.stopPropagation()
|
||||
|
||||
useEditor.getState().setPhase(targetPhase)
|
||||
|
||||
if (targetPhase === 'structure' && useEditor.getState().structureLayer === 'zones') {
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
}
|
||||
|
||||
const strategy = SELECTION_STRATEGIES[targetPhase];
|
||||
const strategy = SELECTION_STRATEGIES[targetPhase]
|
||||
if (strategy) {
|
||||
strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current);
|
||||
strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const allTypes = ["wall", "item", "building", "slab", "ceiling", "roof", "window", "door", "zone", "site"];
|
||||
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);
|
||||
});
|
||||
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]);
|
||||
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 />
|
||||
}
|
||||
|
||||
const EditorOutlinerSync = () => {
|
||||
const phase = useEditor((s) => s.phase);
|
||||
const selection = useViewer((s) => s.selection);
|
||||
const hoveredId = useViewer((s) => s.hoveredId);
|
||||
const outliner = useViewer((s) => s.outliner);
|
||||
const phase = useEditor((s) => s.phase)
|
||||
const selection = useViewer((s) => s.selection)
|
||||
const hoveredId = useViewer((s) => s.hoveredId)
|
||||
const outliner = useViewer((s) => s.outliner)
|
||||
|
||||
useEffect(() => {
|
||||
let idsToHighlight: string[] = [];
|
||||
let idsToHighlight: string[] = []
|
||||
|
||||
// 1. Determine what should be highlighted based on Phase
|
||||
switch (phase) {
|
||||
case "site":
|
||||
case 'site':
|
||||
// Only highlight the building if one is selected
|
||||
if (selection.buildingId) idsToHighlight = [selection.buildingId];
|
||||
break;
|
||||
if (selection.buildingId) idsToHighlight = [selection.buildingId]
|
||||
break
|
||||
|
||||
case "structure":
|
||||
case 'structure':
|
||||
// Highlight selected items (walls/slabs)
|
||||
// We IGNORE buildingId even if it's set in the store
|
||||
idsToHighlight = selection.selectedIds;
|
||||
break;
|
||||
idsToHighlight = selection.selectedIds
|
||||
break
|
||||
|
||||
case "furnish":
|
||||
case 'furnish':
|
||||
// Highlight selected furniture/items
|
||||
idsToHighlight = selection.selectedIds;
|
||||
break;
|
||||
idsToHighlight = selection.selectedIds
|
||||
break
|
||||
|
||||
default:
|
||||
// Pure Viewer mode: Highlight based on the "deepest" selection
|
||||
if (selection.selectedIds.length > 0)
|
||||
idsToHighlight = selection.selectedIds;
|
||||
else if (selection.levelId) idsToHighlight = [selection.levelId];
|
||||
else if (selection.buildingId) idsToHighlight = [selection.buildingId];
|
||||
if (selection.selectedIds.length > 0) idsToHighlight = selection.selectedIds
|
||||
else if (selection.levelId) idsToHighlight = [selection.levelId]
|
||||
else if (selection.buildingId) idsToHighlight = [selection.buildingId]
|
||||
}
|
||||
|
||||
// 2. Sync with the imperative outliner arrays (mutate in place to keep references)
|
||||
outliner.selectedObjects.length = 0;
|
||||
outliner.selectedObjects.length = 0
|
||||
for (const id of idsToHighlight) {
|
||||
const obj = sceneRegistry.nodes.get(id);
|
||||
if (obj) outliner.selectedObjects.push(obj);
|
||||
const obj = sceneRegistry.nodes.get(id)
|
||||
if (obj) outliner.selectedObjects.push(obj)
|
||||
}
|
||||
|
||||
outliner.hoveredObjects.length = 0;
|
||||
outliner.hoveredObjects.length = 0
|
||||
if (hoveredId) {
|
||||
const obj = sceneRegistry.nodes.get(hoveredId);
|
||||
if (obj) outliner.hoveredObjects.push(obj);
|
||||
const obj = sceneRegistry.nodes.get(hoveredId)
|
||||
if (obj) outliner.hoveredObjects.push(obj)
|
||||
}
|
||||
}, [phase, selection, hoveredId, outliner]);
|
||||
}, [phase, selection, hoveredId, outliner])
|
||||
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import type { SiteNode } from '@pascal-app/core'
|
||||
import { sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { createPortal, useFrame } from '@react-three/fiber'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
@@ -50,10 +50,10 @@ export function SiteEdgeLabels() {
|
||||
<Html
|
||||
center
|
||||
key={`edge-${i}`}
|
||||
occlude
|
||||
position={[edge.midX, 0.5, edge.midZ]}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[10, 0]}
|
||||
occlude
|
||||
>
|
||||
<div className="whitespace-nowrap rounded bg-black/75 px-1.5 py-0.5 font-mono text-white text-xs backdrop-blur-sm">
|
||||
{edge.dist.toFixed(2)}m
|
||||
|
||||
@@ -23,7 +23,9 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
const pendingAutoRef = useRef(false)
|
||||
const onThumbnailCaptureRef = useRef(onThumbnailCapture)
|
||||
|
||||
useEffect(() => { onThumbnailCaptureRef.current = onThumbnailCapture }, [onThumbnailCapture])
|
||||
useEffect(() => {
|
||||
onThumbnailCaptureRef.current = onThumbnailCapture
|
||||
}, [onThumbnailCapture])
|
||||
|
||||
const generate = useCallback(async () => {
|
||||
if (isGenerating.current) return
|
||||
@@ -32,7 +34,12 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
isGenerating.current = true
|
||||
|
||||
try {
|
||||
const thumbnailCamera = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
|
||||
const thumbnailCamera = new THREE.PerspectiveCamera(
|
||||
60,
|
||||
THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT,
|
||||
0.1,
|
||||
1000,
|
||||
)
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
const siteNode = Object.values(nodes).find((n) => n.type === 'site')
|
||||
@@ -74,7 +81,10 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
|
||||
const srcAspect = width / height
|
||||
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
|
||||
let sx = 0, sy = 0, sWidth = width, sHeight = height
|
||||
let sx = 0,
|
||||
sy = 0,
|
||||
sWidth = width,
|
||||
sHeight = height
|
||||
if (srcAspect > dstAspect) {
|
||||
sWidth = Math.round(height * dstAspect)
|
||||
sx = Math.round((width - sWidth) / 2)
|
||||
|
||||
@@ -17,9 +17,17 @@ const MAX_IMAGE_SIZE = 5 * 1024 * 1024
|
||||
|
||||
type ImagePreview = { file: File; url: string }
|
||||
|
||||
export function FeedbackDialog({ projectId: projectIdProp, onSubmit }: {
|
||||
export function FeedbackDialog({
|
||||
projectId: projectIdProp,
|
||||
onSubmit,
|
||||
}: {
|
||||
projectId?: string
|
||||
onSubmit?: (data: { message: string; projectId?: string; sceneGraph: unknown; images: File[] }) => Promise<{ success: boolean; error?: string }>
|
||||
onSubmit?: (data: {
|
||||
message: string
|
||||
projectId?: string
|
||||
sceneGraph: unknown
|
||||
images: File[]
|
||||
}) => Promise<{ success: boolean; error?: string }>
|
||||
}) {
|
||||
const projectId = projectIdProp
|
||||
|
||||
@@ -120,7 +128,7 @@ export function FeedbackDialog({ projectId: projectIdProp, onSubmit }: {
|
||||
message,
|
||||
projectId,
|
||||
sceneGraph,
|
||||
images: images.map(img => img.file),
|
||||
images: images.map((img) => img.file),
|
||||
})
|
||||
if (result.success) {
|
||||
setSent(true)
|
||||
@@ -136,14 +144,14 @@ export function FeedbackDialog({ projectId: projectIdProp, onSubmit }: {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90"
|
||||
onClick={handleOpen}
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Feedback
|
||||
</button>
|
||||
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<Dialog onOpenChange={handleClose} open={open}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[460px]"
|
||||
onDragEnter={onDragEnter}
|
||||
@@ -153,10 +161,10 @@ export function FeedbackDialog({ projectId: projectIdProp, onSubmit }: {
|
||||
>
|
||||
{/* Drag overlay — only visible when dragging files over the dialog */}
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-dashed border-primary/50 bg-primary/5 backdrop-blur-sm transition-all">
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-primary/50 border-dashed bg-primary/5 backdrop-blur-sm transition-all">
|
||||
<div className="flex flex-col items-center gap-2 text-primary/70">
|
||||
<ImageIcon className="h-8 w-8" />
|
||||
<p className="text-sm font-medium">Drop images here</p>
|
||||
<p className="font-medium text-sm">Drop images here</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -167,24 +175,24 @@ export function FeedbackDialog({ projectId: projectIdProp, onSubmit }: {
|
||||
</DialogHeader>
|
||||
|
||||
{sent ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
<p className="py-4 text-center text-muted-foreground text-sm">
|
||||
Thanks for your feedback!
|
||||
</p>
|
||||
) : (
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="feedback-message" className="text-sm font-medium">
|
||||
<label className="font-medium text-sm" htmlFor="feedback-message">
|
||||
Your feedback
|
||||
</label>
|
||||
<textarea
|
||||
autoFocus
|
||||
className="mt-1 w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isSubmitting}
|
||||
id="feedback-message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Share your thoughts, suggestions, feature requests, or report issues..."
|
||||
rows={5}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
value={message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -193,14 +201,14 @@ export function FeedbackDialog({ projectId: projectIdProp, onSubmit }: {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{images.map((img, i) => (
|
||||
<div
|
||||
key={img.url}
|
||||
className="group relative h-14 w-14 overflow-hidden rounded-md border border-border"
|
||||
key={img.url}
|
||||
>
|
||||
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
||||
<img alt="" className="h-full w-full object-cover" src={img.url} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(i)}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={() => removeImage(i)}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
@@ -209,41 +217,41 @@ export function FeedbackDialog({ projectId: projectIdProp, onSubmit }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{error && <p className="text-destructive text-sm">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Subtle attach button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-1.5 text-muted-foreground text-xs transition-colors hover:text-foreground disabled:opacity-40"
|
||||
disabled={isSubmitting || images.length >= MAX_IMAGES}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
{images.length > 0 ? `${images.length}/${MAX_IMAGES}` : 'Attach'}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
if (e.target.files) addFiles(e.target.files)
|
||||
e.target.value = ''
|
||||
}}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || !message.trim() || !onSubmit}>
|
||||
<Button disabled={isSubmitting || !message.trim() || !onSubmit} type="submit">
|
||||
{isSubmitting ? 'Sending...' : 'Send Feedback'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { Howl } from 'howler'
|
||||
import { Disc3, Settings2, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Slider } from '../components/ui/slider'
|
||||
import { cn } from '../lib/utils'
|
||||
@@ -163,32 +163,30 @@ export function PascalRadio() {
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'flex flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md',
|
||||
!isOpen && 'cursor-pointer transition-colors hover:bg-accent/90',
|
||||
)}
|
||||
layout
|
||||
onClick={() => {
|
||||
if (!isOpen) setIsOpen(true)
|
||||
}}
|
||||
ref={containerRef}
|
||||
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
|
||||
className={cn(
|
||||
'flex flex-col rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md overflow-hidden',
|
||||
!isOpen && 'cursor-pointer hover:bg-accent/90 transition-colors',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 text-sm font-medium">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 font-medium text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Disc3 className={cn('h-4 w-4 shrink-0', isRadioPlaying && 'animate-spin')} />
|
||||
<span className="hidden sm:inline whitespace-nowrap">Radio Pascal</span>
|
||||
<span className="hidden whitespace-nowrap sm:inline">Radio Pascal</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
aria-label={isRadioPlaying ? 'Pause' : 'Play'}
|
||||
className="cursor-pointer rounded-sm bg-accent/30 p-1 transition-all hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handlePlayPause()
|
||||
}}
|
||||
className="rounded-sm p-1 transition-all cursor-pointer bg-accent/30 hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={isRadioPlaying ? 'Pause' : 'Play'}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
@@ -196,6 +194,8 @@ export function PascalRadio() {
|
||||
handlePlayPause()
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{isRadioPlaying ? (
|
||||
<Volume2 className="h-3.5 w-3.5" />
|
||||
@@ -204,15 +204,15 @@ export function PascalRadio() {
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
aria-label="Radio Settings"
|
||||
className={cn(
|
||||
'cursor-pointer rounded-sm p-1 transition-all hover:bg-accent hover:text-accent-foreground',
|
||||
isOpen && 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsOpen(!isOpen)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-sm p-1 transition-all cursor-pointer hover:bg-accent hover:text-accent-foreground',
|
||||
isOpen && 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
aria-label="Radio Settings"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -222,34 +222,34 @@ export function PascalRadio() {
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
|
||||
>
|
||||
<div className="px-3 pb-3 space-y-3 w-[16rem]">
|
||||
<div className="h-px w-full bg-border/50 mb-3" />
|
||||
<div className="w-[16rem] space-y-3 px-3 pb-3">
|
||||
<div className="mb-3 h-px w-full bg-border/50" />
|
||||
{/* Current song info with prev/next */}
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-2">Now Playing</p>
|
||||
<p className="mb-2 text-muted-foreground text-xs">Now Playing</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
className="rounded-full p-1.5 transition-colors hover:bg-accent shrink-0"
|
||||
aria-label="Previous"
|
||||
className="shrink-0 rounded-full p-1.5 transition-colors hover:bg-accent"
|
||||
onClick={handlePrevious}
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</button>
|
||||
<p
|
||||
className="text-sm font-medium text-center flex-1 truncate"
|
||||
className="flex-1 truncate text-center font-medium text-sm"
|
||||
title={currentTrack.title}
|
||||
>
|
||||
{currentTrack.title}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="rounded-full p-1.5 transition-colors hover:bg-accent shrink-0"
|
||||
aria-label="Next"
|
||||
className="shrink-0 rounded-full p-1.5 transition-colors hover:bg-accent"
|
||||
onClick={handleNext}
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -258,16 +258,16 @@ export function PascalRadio() {
|
||||
|
||||
{/* Volume control */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Volume2 className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<Volume2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<Slider
|
||||
value={[radioVolume]}
|
||||
onValueChange={handleVolumeChange}
|
||||
max={100}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
aria-label="Radio Volume"
|
||||
className="flex-1"
|
||||
max={100}
|
||||
onValueChange={handleVolumeChange}
|
||||
step={1}
|
||||
value={[radioVolume]}
|
||||
/>
|
||||
<span className="w-8 text-right text-xs text-muted-foreground shrink-0">
|
||||
<span className="w-8 shrink-0 text-right text-muted-foreground text-xs">
|
||||
{radioVolume}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -6,11 +6,11 @@ import useEditor from '../store/use-editor'
|
||||
export function PreviewButton() {
|
||||
return (
|
||||
<button
|
||||
className="flex cursor-pointer items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90"
|
||||
onClick={() => useEditor.getState().setPreviewMode(true)}
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4 shrink-0" />
|
||||
<span className="hidden sm:inline whitespace-nowrap">Preview</span>
|
||||
<span className="hidden whitespace-nowrap sm:inline">Preview</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ export const CeilingSystem = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const nodes = useScene.getState().nodes
|
||||
|
||||
|
||||
const levelsToShowCeilings = new Set<string>()
|
||||
|
||||
const isCeilingToolActive =
|
||||
const isCeilingToolActive =
|
||||
tool === 'ceiling' ||
|
||||
selectedItem?.attachTo === 'ceiling' ||
|
||||
(movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling')
|
||||
@@ -45,7 +45,7 @@ export const CeilingSystem = () => {
|
||||
levelsToShowCeilings.add(levelId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const ceilings = sceneRegistry.byType.ceiling
|
||||
ceilings.forEach((ceiling) => {
|
||||
const mesh = sceneRegistry.nodes.get(ceiling)
|
||||
@@ -54,7 +54,7 @@ export const CeilingSystem = () => {
|
||||
if (ceilingGrid) {
|
||||
let belongsToVisibleLevel = false
|
||||
let currentId: string | null = ceiling
|
||||
|
||||
|
||||
while (currentId && nodes[currentId as AnyNodeId]) {
|
||||
const node = nodes[currentId as AnyNodeId]
|
||||
if (node && levelsToShowCeilings.has(node.id)) {
|
||||
@@ -64,8 +64,8 @@ export const CeilingSystem = () => {
|
||||
currentId = node?.parentId as string | null
|
||||
}
|
||||
|
||||
const shouldShowGrid = belongsToVisibleLevel ||
|
||||
(levelsToShowCeilings.size === 0 && isCeilingToolActive)
|
||||
const shouldShowGrid =
|
||||
belongsToVisibleLevel || (levelsToShowCeilings.size === 0 && isCeilingToolActive)
|
||||
|
||||
ceilingGrid.visible = shouldShowGrid
|
||||
ceilingGrid.scale.setScalar(shouldShowGrid ? 1 : 0.0) // Scale down to zero to prevent event interference when grid is hidden
|
||||
|
||||
@@ -21,7 +21,9 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
||||
|
||||
// Keep a ref so the click handler never has a stale zone name
|
||||
const zoneNameRef = useRef(zone?.name ?? '')
|
||||
useEffect(() => { zoneNameRef.current = zone?.name ?? '' }, [zone?.name])
|
||||
useEffect(() => {
|
||||
zoneNameRef.current = zone?.name ?? ''
|
||||
}, [zone?.name])
|
||||
|
||||
// Setup: find the label element, enable pointer events, and hide the
|
||||
// zone-renderer's own text node (children[0]) — we replace it via portal.
|
||||
@@ -87,22 +89,26 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
||||
return createPortal(
|
||||
editing ? (
|
||||
<div
|
||||
style={sharedStyle}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={sharedStyle}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onBlur={save}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') { e.preventDefault(); save() }
|
||||
if (e.key === 'Escape') { e.preventDefault(); cancel() }
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
save()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancel()
|
||||
}
|
||||
}}
|
||||
onBlur={save}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
ref={inputRef}
|
||||
style={{
|
||||
width: `${Math.max((value || zone?.name || '').length + 1, 4)}ch`,
|
||||
border: 'none',
|
||||
@@ -118,10 +124,14 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
||||
fontFamily: 'inherit',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
type="text"
|
||||
value={value}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); save() }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
save()
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'none',
|
||||
@@ -132,14 +142,13 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...sharedStyle, background: 'none', border: 'none', cursor: 'text', padding: 0 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSelection({ zoneId })
|
||||
@@ -147,6 +156,8 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
||||
setEditing(true)
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
style={{ ...sharedStyle, background: 'none', border: 'none', cursor: 'text', padding: 0 }}
|
||||
type="button"
|
||||
>
|
||||
<span>{zone?.name}</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', opacity: 0.55 }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveLevelId, type CeilingNode, useScene } from '@pascal-app/core'
|
||||
import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback } from 'react'
|
||||
import { PolygonEditor } from '../shared/polygon-editor'
|
||||
@@ -27,15 +27,15 @@ export const CeilingBoundaryEditor: React.FC<CeilingBoundaryEditorProps> = ({ ce
|
||||
[ceilingId, updateNode, setSelection],
|
||||
)
|
||||
|
||||
if (!ceiling || !ceiling.polygon || ceiling.polygon.length < 3) return null
|
||||
if (!(ceiling && ceiling.polygon) || ceiling.polygon.length < 3) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
polygon={ceiling.polygon}
|
||||
color="#d4d4d4"
|
||||
onPolygonChange={handlePolygonChange}
|
||||
minVertices={3}
|
||||
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
|
||||
minVertices={3}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
polygon={ceiling.polygon}
|
||||
surfaceHeight={ceiling.height ?? 2.5}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveLevelId, type CeilingNode, useScene } from '@pascal-app/core'
|
||||
import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback } from 'react'
|
||||
import { PolygonEditor } from '../shared/polygon-editor'
|
||||
@@ -32,15 +32,15 @@ export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId,
|
||||
[ceilingId, holeIndex, holes, updateNode, setSelection],
|
||||
)
|
||||
|
||||
if (!ceiling || !hole || hole.length < 3) return null
|
||||
if (!(ceiling && hole) || hole.length < 3) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
polygon={hole}
|
||||
color="#ef4444" // red for holes
|
||||
onPolygonChange={handlePolygonChange}
|
||||
color="#ef4444"
|
||||
levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes
|
||||
minVertices={3}
|
||||
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
polygon={hole}
|
||||
surfaceHeight={ceiling.height ?? 2.5}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } from 'three'
|
||||
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
||||
import { mix, positionLocal } from 'three/tsl'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
@@ -37,19 +37,22 @@ const calculateSnapPoint = (
|
||||
// Snap to 45° diagonal
|
||||
const diagonalLength = Math.min(absDx, absDy)
|
||||
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
||||
} else if (minDist === horizontalDist) {
|
||||
}
|
||||
if (minDist === horizontalDist) {
|
||||
// Snap to horizontal
|
||||
return [x, y1]
|
||||
} else {
|
||||
// Snap to vertical
|
||||
return [x1, y]
|
||||
}
|
||||
// Snap to vertical
|
||||
return [x1, y]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ceiling with the given polygon points and returns its ID
|
||||
*/
|
||||
const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => {
|
||||
const commitCeilingDrawing = (
|
||||
levelId: LevelNode['id'],
|
||||
points: Array<[number, number]>,
|
||||
): string => {
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
|
||||
// Count existing ceilings for naming
|
||||
@@ -86,7 +89,11 @@ export const CeilingTool: React.FC = () => {
|
||||
|
||||
// Static geometry: local y goes 0 (grid) → H (ceiling), mesh is positioned at gridY
|
||||
const verticalGeo = useMemo(
|
||||
() => new BufferGeometry().setFromPoints([new Vector3(0, 0, 0), new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0)]),
|
||||
() =>
|
||||
new BufferGeometry().setFromPoints([
|
||||
new Vector3(0, 0, 0),
|
||||
new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0),
|
||||
]),
|
||||
[],
|
||||
)
|
||||
|
||||
@@ -101,7 +108,7 @@ export const CeilingTool: React.FC = () => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current || !gridCursorRef.current) return
|
||||
if (!(cursorRef.current && gridCursorRef.current)) return
|
||||
|
||||
const gridX = Math.round(event.position[0] * 2) / 2
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
@@ -205,7 +212,7 @@ export const CeilingTool: React.FC = () => {
|
||||
|
||||
// Update line geometries when points change
|
||||
useEffect(() => {
|
||||
if (!mainLineRef.current || !closingLineRef.current) return
|
||||
if (!(mainLineRef.current && closingLineRef.current)) return
|
||||
|
||||
if (points.length === 0) {
|
||||
mainLineRef.current.visible = false
|
||||
@@ -254,7 +261,9 @@ export const CeilingTool: React.FC = () => {
|
||||
new Vector3(firstPoint[0], gridY, firstPoint[1]),
|
||||
]
|
||||
groundClosingLineRef.current.geometry.dispose()
|
||||
groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints(groundClosingPoints)
|
||||
groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints(
|
||||
groundClosingPoints,
|
||||
)
|
||||
groundClosingLineRef.current.visible = true
|
||||
} else {
|
||||
closingLineRef.current.visible = false
|
||||
@@ -296,15 +305,33 @@ export const CeilingTool: React.FC = () => {
|
||||
<CursorSphere ref={cursorRef} />
|
||||
|
||||
{/* Grid-level cursor indicator */}
|
||||
<mesh ref={gridCursorRef} rotation={[-Math.PI / 2, 0, 0]} renderOrder={2} layers={EDITOR_LAYER}>
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
ref={gridCursorRef}
|
||||
renderOrder={2}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<ringGeometry args={[0.15, 0.2, 32]} />
|
||||
<meshBasicMaterial color="#818cf8" side={DoubleSide} depthTest={false} depthWrite={true} opacity={0.5} transparent />
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={true}
|
||||
opacity={0.5}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={verticalLineRef} geometry={verticalGeo} renderOrder={1} layers={EDITOR_LAYER}>
|
||||
<lineBasicNodeMaterial color="#818cf8" opacityNode={gradientOpacityNode} depthTest={false} depthWrite={false} transparent />
|
||||
<line geometry={verticalGeo} layers={EDITOR_LAYER} ref={verticalLineRef} renderOrder={1}>
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacityNode={gradientOpacityNode}
|
||||
transparent
|
||||
/>
|
||||
</line>
|
||||
|
||||
{/* Preview fill (Top) */}
|
||||
@@ -347,20 +374,32 @@ export const CeilingTool: React.FC = () => {
|
||||
|
||||
{/* Main line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={mainLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} />
|
||||
<lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
|
||||
</line>
|
||||
|
||||
{/* Closing line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={closingLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
linewidth={2}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={2}
|
||||
opacity={0.5}
|
||||
transparent
|
||||
/>
|
||||
@@ -368,20 +407,39 @@ export const CeilingTool: React.FC = () => {
|
||||
|
||||
{/* Ground main line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={groundMainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={groundMainLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} opacity={0.3} transparent />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={3}
|
||||
opacity={0.3}
|
||||
transparent
|
||||
/>
|
||||
</line>
|
||||
|
||||
{/* Ground closing line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={groundClosingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={groundClosingLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
linewidth={2}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={2}
|
||||
opacity={0.15}
|
||||
transparent
|
||||
/>
|
||||
@@ -390,9 +448,9 @@ export const CeilingTool: React.FC = () => {
|
||||
{/* Point markers */}
|
||||
{points.map(([x, z], index) => (
|
||||
<CursorSphere
|
||||
color="#818cf8"
|
||||
key={index}
|
||||
position={[x, levelY + CEILING_HEIGHT + 0.01, z]}
|
||||
color="#818cf8"
|
||||
showTooltip={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { type AnyNodeId, type DoorNode, getScaledDimensions, type ItemNode, useScene, type WallNode, type WindowNode } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type DoorNode,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
|
||||
@@ -36,7 +44,7 @@ export function clampToWall(
|
||||
const wallLength = Math.sqrt(dx * dx + dz * dz)
|
||||
|
||||
const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))
|
||||
const clampedY = height / 2 // Doors always sit at floor level
|
||||
const clampedY = height / 2 // Doors always sit at floor level
|
||||
return { clampedX, clampedY }
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
@@ -19,11 +21,9 @@ import {
|
||||
snapToHalf,
|
||||
} from '../item/placement-math'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef4444,
|
||||
color: 0xef_44_44,
|
||||
linewidth: 3,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
@@ -79,7 +79,7 @@ export const DoorTool: React.FC = () => {
|
||||
group.visible = true
|
||||
group.position.set(...worldPosition)
|
||||
group.rotation.y = cursorRotationY
|
||||
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
@@ -115,7 +115,13 @@ export const DoorTool: React.FC = () => {
|
||||
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
@@ -147,12 +153,22 @@ export const DoorTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY, width, height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
width,
|
||||
height,
|
||||
draftRef.current?.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
@@ -169,12 +185,17 @@ export const DoorTool: React.FC = () => {
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node, localX,
|
||||
draftRef.current.width, draftRef.current.height,
|
||||
event.node,
|
||||
localX,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
)
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY,
|
||||
draftRef.current.width, draftRef.current.height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
draftRef.current.id,
|
||||
)
|
||||
if (!valid) return
|
||||
@@ -261,7 +282,12 @@ export const DoorTool: React.FC = () => {
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments ref={edgesRef} geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
|
||||
<lineSegments
|
||||
geometry={edgesGeo}
|
||||
layers={EDITOR_LAYER}
|
||||
material={edgeMaterial}
|
||||
ref={edgesRef}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef4444,
|
||||
color: 0xef_44_44,
|
||||
linewidth: 3,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
@@ -40,9 +40,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const meta = (typeof movingDoorNode.metadata === 'object' && movingDoorNode.metadata !== null)
|
||||
? movingDoorNode.metadata as Record<string, unknown>
|
||||
: {}
|
||||
const meta =
|
||||
typeof movingDoorNode.metadata === 'object' && movingDoorNode.metadata !== null
|
||||
? (movingDoorNode.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
|
||||
const original = {
|
||||
@@ -92,7 +93,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
group.visible = true
|
||||
group.position.set(...worldPosition)
|
||||
group.rotation.y = cursorRotationY
|
||||
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
@@ -105,8 +106,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node, localX,
|
||||
movingDoorNode.width, movingDoorNode.height,
|
||||
event.node,
|
||||
localX,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
)
|
||||
|
||||
const prevWallId = currentWallId
|
||||
@@ -124,13 +127,22 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
markWallDirty(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY,
|
||||
movingDoorNode.width, movingDoorNode.height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
movingDoorNode.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
@@ -147,8 +159,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node, localX,
|
||||
movingDoorNode.width, movingDoorNode.height,
|
||||
event.node,
|
||||
localX,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
)
|
||||
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
@@ -166,13 +180,22 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
markWallDirty(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY,
|
||||
movingDoorNode.width, movingDoorNode.height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
movingDoorNode.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
@@ -188,13 +211,18 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node, localX,
|
||||
movingDoorNode.width, movingDoorNode.height,
|
||||
event.node,
|
||||
localX,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY,
|
||||
movingDoorNode.width, movingDoorNode.height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
movingDoorNode.id,
|
||||
)
|
||||
if (!valid) return
|
||||
@@ -297,7 +325,9 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as DoorNode | undefined
|
||||
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
|
||||
| DoorNode
|
||||
| undefined
|
||||
const currentMeta = current?.metadata as Record<string, unknown> | undefined
|
||||
if (currentMeta?.isTransient) {
|
||||
if (isNew) {
|
||||
@@ -337,7 +367,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
|
||||
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,16 +25,19 @@ function getInitialState(node: {
|
||||
function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
|
||||
const draftNode = useDraftNode()
|
||||
|
||||
const meta = (typeof movingNode.metadata === 'object' && movingNode.metadata !== null)
|
||||
? movingNode.metadata as Record<string, unknown>
|
||||
: {}
|
||||
const meta =
|
||||
typeof movingNode.metadata === 'object' && movingNode.metadata !== null
|
||||
? (movingNode.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
|
||||
const cursor = usePlacementCoordinator({
|
||||
asset: movingNode.asset,
|
||||
draftNode,
|
||||
// Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft
|
||||
initialState: isNew ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } : getInitialState(movingNode),
|
||||
initialState: isNew
|
||||
? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }
|
||||
: getInitialState(movingNode),
|
||||
// Preserve the original item's scale so Y-position calculations use the correct height
|
||||
defaultScale: isNew ? movingNode.scale : undefined,
|
||||
initDraft: (gridPosition) => {
|
||||
|
||||
@@ -35,9 +35,8 @@ export function calculateCursorRotation(
|
||||
// In local wall space, front face has normal.z < 0, back face has normal.z > 0
|
||||
if (normal[2] < 0) {
|
||||
return -wallAngle
|
||||
} else {
|
||||
return Math.PI - wallAngle
|
||||
}
|
||||
return Math.PI - wallAngle
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,14 +11,6 @@ import type {
|
||||
} from '@pascal-app/core'
|
||||
import { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { Vector3 } from 'three'
|
||||
import type {
|
||||
CommitResult,
|
||||
LevelResolver,
|
||||
PlacementContext,
|
||||
PlacementResult,
|
||||
SpatialValidators,
|
||||
TransitionResult,
|
||||
} from './placement-types'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
@@ -28,6 +20,14 @@ import {
|
||||
snapToHalf,
|
||||
stripTransient,
|
||||
} from './placement-math'
|
||||
import type {
|
||||
CommitResult,
|
||||
LevelResolver,
|
||||
PlacementContext,
|
||||
PlacementResult,
|
||||
SpatialValidators,
|
||||
TransitionResult,
|
||||
} from './placement-types'
|
||||
|
||||
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
||||
|
||||
@@ -43,7 +43,9 @@ export const floorStrategy = {
|
||||
move(ctx: PlacementContext, event: GridEvent): PlacementResult | null {
|
||||
if (ctx.state.surface !== 'floor') return null
|
||||
|
||||
const dims = ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const dims = ctx.draftItem
|
||||
? getScaledDimensions(ctx.draftItem)
|
||||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const [dimX, , dimZ] = dims
|
||||
const x = snapToGrid(event.position[0], dimX)
|
||||
const z = snapToGrid(event.position[2], dimZ)
|
||||
@@ -62,9 +64,13 @@ export const floorStrategy = {
|
||||
* Handle grid:click — commit placement on floor.
|
||||
* Returns null if on wall/ceiling or validation fails.
|
||||
*/
|
||||
click(ctx: PlacementContext, _event: GridEvent, validators: SpatialValidators): CommitResult | null {
|
||||
click(
|
||||
ctx: PlacementContext,
|
||||
_event: GridEvent,
|
||||
validators: SpatialValidators,
|
||||
): CommitResult | null {
|
||||
if (ctx.state.surface !== 'floor') return null
|
||||
if (!ctx.levelId || !ctx.draftItem) return null
|
||||
if (!(ctx.levelId && ctx.draftItem)) return null
|
||||
|
||||
const pos: [number, number, number] = [ctx.gridPosition.x, 0, ctx.gridPosition.z]
|
||||
const valid = validators.canPlaceOnFloor(
|
||||
@@ -128,7 +134,9 @@ export const wallStrategy = {
|
||||
event.node.id,
|
||||
x,
|
||||
y,
|
||||
ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS),
|
||||
ctx.draftItem
|
||||
? getScaledDimensions(ctx.draftItem)
|
||||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS),
|
||||
attachTo,
|
||||
side,
|
||||
[],
|
||||
@@ -160,9 +168,13 @@ export const wallStrategy = {
|
||||
* Returns null if not on a wall or face is invalid.
|
||||
* Auto-adjusts Y position to fit within wall bounds.
|
||||
*/
|
||||
move(ctx: PlacementContext, event: WallEvent, validators: SpatialValidators): PlacementResult | null {
|
||||
move(
|
||||
ctx: PlacementContext,
|
||||
event: WallEvent,
|
||||
validators: SpatialValidators,
|
||||
): PlacementResult | null {
|
||||
if (ctx.state.surface !== 'wall') return null
|
||||
if (!ctx.draftItem || !ctx.levelId) return null
|
||||
if (!(ctx.draftItem && ctx.levelId)) return null
|
||||
if (!isValidWallSideFace(event.normal)) return null
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
@@ -209,10 +221,14 @@ export const wallStrategy = {
|
||||
* Handle wall:click — commit placement on wall.
|
||||
* Returns null if not on wall, face invalid, or validation fails.
|
||||
*/
|
||||
click(ctx: PlacementContext, event: WallEvent, validators: SpatialValidators): CommitResult | null {
|
||||
click(
|
||||
ctx: PlacementContext,
|
||||
event: WallEvent,
|
||||
validators: SpatialValidators,
|
||||
): CommitResult | null {
|
||||
if (ctx.state.surface !== 'wall') return null
|
||||
if (!isValidWallSideFace(event.normal)) return null
|
||||
if (!ctx.levelId || !ctx.draftItem) return null
|
||||
if (!(ctx.levelId && ctx.draftItem)) return null
|
||||
|
||||
const valid = validators.canPlaceOnWall(
|
||||
ctx.levelId,
|
||||
@@ -281,7 +297,9 @@ export const ceilingStrategy = {
|
||||
const ceilingLevelId = resolveLevelId(event.node, nodes)
|
||||
if (ctx.levelId !== ceilingLevelId) return null
|
||||
|
||||
const dims = ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const dims = ctx.draftItem
|
||||
? getScaledDimensions(ctx.draftItem)
|
||||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const [dimX, , dimZ] = dims
|
||||
const itemHeight = dims[1]
|
||||
|
||||
@@ -328,7 +346,11 @@ export const ceilingStrategy = {
|
||||
/**
|
||||
* Handle ceiling:click — commit placement on ceiling.
|
||||
*/
|
||||
click(ctx: PlacementContext, event: CeilingEvent, validators: SpatialValidators): CommitResult | null {
|
||||
click(
|
||||
ctx: PlacementContext,
|
||||
event: CeilingEvent,
|
||||
validators: SpatialValidators,
|
||||
): CommitResult | null {
|
||||
if (ctx.state.surface !== 'ceiling') return null
|
||||
if (!ctx.draftItem) return null
|
||||
|
||||
@@ -399,7 +421,9 @@ export const itemSurfaceStrategy = {
|
||||
if (!surfaceItem.asset.surface) return null
|
||||
|
||||
// Size check: our footprint must fit on surface item's footprint
|
||||
const ourDims = ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const ourDims = ctx.draftItem
|
||||
? getScaledDimensions(ctx.draftItem)
|
||||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const surfDims = getScaledDimensions(surfaceItem)
|
||||
if (ourDims[0] > surfDims[0] || ourDims[2] > surfDims[2]) return null
|
||||
|
||||
@@ -430,7 +454,7 @@ export const itemSurfaceStrategy = {
|
||||
*/
|
||||
move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
|
||||
if (ctx.state.surface !== 'item-surface') return null
|
||||
if (!ctx.state.surfaceItemId || !ctx.draftItem) return null
|
||||
if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
|
||||
@@ -464,7 +488,7 @@ export const itemSurfaceStrategy = {
|
||||
*/
|
||||
click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
|
||||
if (ctx.state.surface !== 'item-surface') return null
|
||||
if (!ctx.draftItem || !ctx.state.surfaceItemId) return null
|
||||
if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null
|
||||
|
||||
return {
|
||||
nodeUpdate: {
|
||||
@@ -487,7 +511,7 @@ export const itemSurfaceStrategy = {
|
||||
* Switches on the active surface type and calls the appropriate spatial validator.
|
||||
*/
|
||||
export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean {
|
||||
if (!ctx.levelId || !ctx.draftItem) return false
|
||||
if (!(ctx.levelId && ctx.draftItem)) return false
|
||||
|
||||
// Item surface: valid if we entered (size check was in enter)
|
||||
if (ctx.state.surface === 'item-surface') {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { AnyNode, AssetInput, CeilingNode, ItemNode, LevelNode, WallNode } from '@pascal-app/core'
|
||||
import type {
|
||||
AnyNode,
|
||||
AssetInput,
|
||||
CeilingNode,
|
||||
ItemNode,
|
||||
LevelNode,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import type { Vector3 } from 'three'
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { type AnyNodeId, type AssetInput, ItemNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type AssetInput,
|
||||
ItemNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import type { Vector3 } from 'three'
|
||||
@@ -18,7 +24,12 @@ export interface DraftNodeHandle {
|
||||
/** Whether the current draft was adopted (move mode) vs created (create mode) */
|
||||
readonly isAdopted: boolean
|
||||
/** Create a new draft item at the given position. Returns the created node or null. */
|
||||
create: (gridPosition: Vector3, asset: AssetInput, rotation?: [number, number, number], scale?: [number, number, number]) => ItemNode | null
|
||||
create: (
|
||||
gridPosition: Vector3,
|
||||
asset: AssetInput,
|
||||
rotation?: [number, number, number],
|
||||
scale?: [number, number, number],
|
||||
) => ItemNode | null
|
||||
/** Take ownership of an existing scene node as the draft (for move mode). */
|
||||
adopt: (node: ItemNode) => void
|
||||
/** Commit the current draft. Create mode: delete+recreate. Move mode: update in place. */
|
||||
@@ -40,32 +51,41 @@ export function useDraftNode(): DraftNodeHandle {
|
||||
const adoptedRef = useRef(false)
|
||||
const originalStateRef = useRef<OriginalState | null>(null)
|
||||
|
||||
const create = useCallback((gridPosition: Vector3, asset: AssetInput, rotation?: [number, number, number], scale?: [number, number, number]): ItemNode | null => {
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
if (!currentLevelId) return null
|
||||
const create = useCallback(
|
||||
(
|
||||
gridPosition: Vector3,
|
||||
asset: AssetInput,
|
||||
rotation?: [number, number, number],
|
||||
scale?: [number, number, number],
|
||||
): ItemNode | null => {
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
if (!currentLevelId) return null
|
||||
|
||||
const node = ItemNode.parse({
|
||||
position: [gridPosition.x, gridPosition.y, gridPosition.z],
|
||||
rotation: rotation ?? [0, 0, 0],
|
||||
scale: scale ?? [1, 1, 1],
|
||||
name: asset.name,
|
||||
asset,
|
||||
parentId: currentLevelId,
|
||||
metadata: { isTransient: true },
|
||||
})
|
||||
const node = ItemNode.parse({
|
||||
position: [gridPosition.x, gridPosition.y, gridPosition.z],
|
||||
rotation: rotation ?? [0, 0, 0],
|
||||
scale: scale ?? [1, 1, 1],
|
||||
name: asset.name,
|
||||
asset,
|
||||
parentId: currentLevelId,
|
||||
metadata: { isTransient: true },
|
||||
})
|
||||
|
||||
useScene.getState().createNode(node, currentLevelId)
|
||||
draftRef.current = node
|
||||
adoptedRef.current = false
|
||||
originalStateRef.current = null
|
||||
return node
|
||||
}, [])
|
||||
useScene.getState().createNode(node, currentLevelId)
|
||||
draftRef.current = node
|
||||
adoptedRef.current = false
|
||||
originalStateRef.current = null
|
||||
return node
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const adopt = useCallback((node: ItemNode): void => {
|
||||
// Save original state so destroy() can restore it
|
||||
const meta = (typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata))
|
||||
? node.metadata as Record<string, unknown>
|
||||
: {}
|
||||
const meta =
|
||||
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
|
||||
? (node.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
|
||||
originalStateRef.current = {
|
||||
position: [...node.position] as [number, number, number],
|
||||
@@ -91,7 +111,8 @@ export function useDraftNode(): DraftNodeHandle {
|
||||
if (adoptedRef.current) {
|
||||
// Move mode: update in place (single undoable action)
|
||||
const { parentId: newParentId, ...updateProps } = finalUpdate
|
||||
const parentId = newParentId ?? originalStateRef.current?.parentId ?? useViewer.getState().selection.levelId
|
||||
const parentId =
|
||||
newParentId ?? originalStateRef.current?.parentId ?? useViewer.getState().selection.levelId
|
||||
const original = originalStateRef.current!
|
||||
|
||||
// Restore original state while paused — so the undo baseline is clean
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
type AnyNodeId,
|
||||
type CeilingEvent,
|
||||
emitter,
|
||||
getScaledDimensions,
|
||||
type GridEvent,
|
||||
getScaledDimensions,
|
||||
type ItemEvent,
|
||||
resolveLevelId,
|
||||
sceneRegistry,
|
||||
@@ -32,7 +32,13 @@ import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { ceilingStrategy, checkCanPlace, floorStrategy, itemSurfaceStrategy, wallStrategy } from './placement-strategies'
|
||||
import {
|
||||
ceilingStrategy,
|
||||
checkCanPlace,
|
||||
floorStrategy,
|
||||
itemSurfaceStrategy,
|
||||
wallStrategy,
|
||||
} from './placement-strategies'
|
||||
import type { PlacementState, TransitionResult } from './placement-types'
|
||||
import type { DraftNodeHandle } from './use-draft-node'
|
||||
|
||||
@@ -41,14 +47,14 @@ const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
||||
// Shared materials for placement cursor - we just change colors, not swap materials
|
||||
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef4444, // red-500 (invalid)
|
||||
color: 0xef_44_44, // red-500 (invalid)
|
||||
linewidth: 3,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
const basePlaneMaterial = new MeshBasicNodeMaterial({
|
||||
color: 0xef4444, // red-500 (invalid)
|
||||
color: 0xef_44_44, // red-500 (invalid)
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
@@ -111,13 +117,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
state: { ...placementState.current },
|
||||
})
|
||||
|
||||
const getActiveValidators = () => shiftFreeRef.current
|
||||
? { canPlaceOnFloor: () => ({ valid: true }), canPlaceOnWall: () => ({ valid: true }), canPlaceOnCeiling: () => ({ valid: true }) }
|
||||
: validators
|
||||
const getActiveValidators = () =>
|
||||
shiftFreeRef.current
|
||||
? {
|
||||
canPlaceOnFloor: () => ({ valid: true }),
|
||||
canPlaceOnWall: () => ({ valid: true }),
|
||||
canPlaceOnCeiling: () => ({ valid: true }),
|
||||
}
|
||||
: validators
|
||||
|
||||
const revalidate = (): boolean => {
|
||||
const placeable = shiftFreeRef.current || checkCanPlace(getContext(), validators)
|
||||
const color = placeable ? 0x22c55e : 0xef4444 // green-500 : red-500
|
||||
const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
|
||||
edgeMaterial.color.setHex(color)
|
||||
basePlaneMaterial.color.setHex(color)
|
||||
return placeable
|
||||
@@ -143,7 +154,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
cursorGroupRef.current.position.set(...result.cursorPosition)
|
||||
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
||||
|
||||
draftNode.create(gridPosition.current, asset, [0, result.cursorRotationY, 0], configRef.current.defaultScale)
|
||||
draftNode.create(
|
||||
gridPosition.current,
|
||||
asset,
|
||||
[0, result.cursorRotationY, 0],
|
||||
configRef.current.defaultScale,
|
||||
)
|
||||
|
||||
const draft = draftNode.current
|
||||
if (draft) {
|
||||
@@ -224,7 +240,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
const nodes = useScene.getState().nodes
|
||||
const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, getActiveValidators())
|
||||
const result = wallStrategy.enter(
|
||||
getContext(),
|
||||
event,
|
||||
resolveLevelId,
|
||||
nodes,
|
||||
getActiveValidators(),
|
||||
)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
@@ -246,7 +268,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
if (ctx.state.surface !== 'wall') {
|
||||
const nodes = useScene.getState().nodes
|
||||
const enterResult = wallStrategy.enter(ctx, event, resolveLevelId, nodes, getActiveValidators())
|
||||
const enterResult = wallStrategy.enter(
|
||||
ctx,
|
||||
event,
|
||||
resolveLevelId,
|
||||
nodes,
|
||||
getActiveValidators(),
|
||||
)
|
||||
if (!enterResult) return
|
||||
|
||||
event.stopPropagation()
|
||||
@@ -262,7 +290,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
if (!draftNode.current) {
|
||||
const nodes = useScene.getState().nodes
|
||||
const setup = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, getActiveValidators())
|
||||
const setup = wallStrategy.enter(
|
||||
getContext(),
|
||||
event,
|
||||
resolveLevelId,
|
||||
nodes,
|
||||
getActiveValidators(),
|
||||
)
|
||||
if (!setup) return
|
||||
|
||||
event.stopPropagation()
|
||||
@@ -334,7 +368,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
if (configRef.current.onCommitted()) {
|
||||
const nodes = useScene.getState().nodes
|
||||
const enterResult = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, validators)
|
||||
const enterResult = wallStrategy.enter(
|
||||
getContext(),
|
||||
event,
|
||||
resolveLevelId,
|
||||
nodes,
|
||||
validators,
|
||||
)
|
||||
if (enterResult) {
|
||||
applyTransition(enterResult)
|
||||
} else {
|
||||
@@ -703,7 +743,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
const viewerLevelId = useViewer((s) => s.selection.levelId)
|
||||
useEffect(() => {
|
||||
const draft = draftNode.current
|
||||
if (!draft || !viewerLevelId || asset.attachTo) return
|
||||
if (!(draft && viewerLevelId) || asset.attachTo) return
|
||||
if (draft.parentId === viewerLevelId) return
|
||||
draft.parentId = viewerLevelId
|
||||
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
|
||||
@@ -749,7 +789,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
})
|
||||
|
||||
const initialDraft = draftNode.current
|
||||
const dims = initialDraft ? getScaledDimensions(initialDraft) : (config.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const dims = initialDraft
|
||||
? getScaledDimensions(initialDraft)
|
||||
: (config.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
||||
initialBoxGeometry.translate(0, dims[1] / 2, 0)
|
||||
|
||||
@@ -760,10 +802,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef}>
|
||||
<lineSegments ref={edgesRef} material={edgeMaterial} layers={EDITOR_LAYER}>
|
||||
<lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef}>
|
||||
<edgesGeometry args={[initialBoxGeometry]} />
|
||||
</lineSegments>
|
||||
<mesh ref={basePlaneRef} geometry={basePlaneGeometry} material={basePlaneMaterial} layers={EDITOR_LAYER} />
|
||||
<mesh
|
||||
geometry={basePlaneGeometry}
|
||||
layers={EDITOR_LAYER}
|
||||
material={basePlaneMaterial}
|
||||
ref={basePlaneRef}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Line, type Group, Vector3 } from 'three'
|
||||
import { BufferGeometry, DoubleSide, type Group, type Line, Vector3 } from 'three'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
@@ -90,7 +90,7 @@ export const RoofTool: React.FC = () => {
|
||||
corner2: [number, number, number],
|
||||
) => {
|
||||
const gridY = corner1[1] + GRID_OFFSET
|
||||
|
||||
|
||||
const groundPoints = [
|
||||
new Vector3(corner1[0], gridY, corner1[2]),
|
||||
new Vector3(corner2[0], gridY, corner1[2]),
|
||||
@@ -98,7 +98,7 @@ export const RoofTool: React.FC = () => {
|
||||
new Vector3(corner1[0], gridY, corner2[2]),
|
||||
new Vector3(corner1[0], gridY, corner1[2]), // Close the loop
|
||||
]
|
||||
|
||||
|
||||
outlineRef.current.geometry.dispose()
|
||||
outlineRef.current.geometry = new BufferGeometry().setFromPoints(groundPoints)
|
||||
outlineRef.current.visible = true
|
||||
@@ -116,7 +116,7 @@ export const RoofTool: React.FC = () => {
|
||||
|
||||
// Update cursors
|
||||
const gridY = y + GRID_OFFSET
|
||||
|
||||
|
||||
cursorRef.current.position.set(gridX, gridY, gridZ)
|
||||
|
||||
// Play snap sound when grid position changes (only when placing)
|
||||
@@ -149,14 +149,7 @@ export const RoofTool: React.FC = () => {
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
const y = event.position[1]
|
||||
|
||||
if (!corner1Ref.current) {
|
||||
// First click - set corner 1
|
||||
corner1Ref.current = [gridX, y, gridZ]
|
||||
setPreview((prev) => ({
|
||||
...prev,
|
||||
corner1: corner1Ref.current,
|
||||
}))
|
||||
} else {
|
||||
if (corner1Ref.current) {
|
||||
// Second click - create the roof
|
||||
const roofId = commitRoofPlacement(currentLevelId, corner1Ref.current, [gridX, y, gridZ])
|
||||
|
||||
@@ -166,6 +159,13 @@ export const RoofTool: React.FC = () => {
|
||||
// Reset state
|
||||
corner1Ref.current = null
|
||||
outlineRef.current.visible = false
|
||||
} else {
|
||||
// First click - set corner 1
|
||||
corner1Ref.current = [gridX, y, gridZ]
|
||||
setPreview((prev) => ({
|
||||
...prev,
|
||||
corner1: corner1Ref.current,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,16 +211,29 @@ export const RoofTool: React.FC = () => {
|
||||
|
||||
{/* Outline showing rectangle being drawn (Ground) */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={outlineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={outlineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial color="#818cf8" linewidth={2} depthTest={false} depthWrite={false} opacity={0.3} transparent />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={2}
|
||||
opacity={0.3}
|
||||
transparent
|
||||
/>
|
||||
</line>
|
||||
|
||||
{/* First corner marker */}
|
||||
{corner1 && (
|
||||
<CursorSphere
|
||||
position={[corner1[0], levelY + GRID_OFFSET, corner1[2]]}
|
||||
color="#818cf8"
|
||||
<CursorSphere
|
||||
color="#818cf8"
|
||||
position={[corner1[0], levelY + GRID_OFFSET, corner1[2]]}
|
||||
showTooltip={false}
|
||||
/>
|
||||
)}
|
||||
@@ -235,11 +248,11 @@ export const RoofTool: React.FC = () => {
|
||||
<planeGeometry args={[previewDimensions.length, previewDimensions.width]} />
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
opacity={0.1}
|
||||
transparent
|
||||
side={DoubleSide}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.1}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Html } from '@react-three/drei'
|
||||
import type { ThreeElements } from '@react-three/fiber'
|
||||
import { forwardRef } from 'react'
|
||||
import type { Group } from 'three'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { furnishTools } from '../../../components/ui/action-menu/furnish-tools'
|
||||
import { tools } from '../../../components/ui/action-menu/structure-tools'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
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['group'], 'ref'> {
|
||||
color?: string
|
||||
@@ -37,31 +37,49 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
||||
{/* Flat marker on the ground */}
|
||||
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||
{/* Center dot */}
|
||||
<mesh renderOrder={2} layers={EDITOR_LAYER}>
|
||||
<mesh layers={EDITOR_LAYER} renderOrder={2}>
|
||||
<circleGeometry args={[0.06, 32]} />
|
||||
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} transparent opacity={0.9} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.9}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
|
||||
{/* Outer ring / glow */}
|
||||
<mesh renderOrder={2} layers={EDITOR_LAYER}>
|
||||
<mesh layers={EDITOR_LAYER} renderOrder={2}>
|
||||
<circleGeometry args={[0.2, 32]} />
|
||||
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} transparent opacity={0.25} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.25}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{/* Vertical line */}
|
||||
{height > 0 && (
|
||||
<mesh position={[0, height / 2, 0]} renderOrder={2} layers={EDITOR_LAYER}>
|
||||
<mesh layers={EDITOR_LAYER} 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} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.7}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Tool Icon Tooltip at the top of the line */}
|
||||
{showTooltip && activeToolConfig && (
|
||||
<Html
|
||||
position={[0, height > 0 ? height + 0.2 : 0.6, 0]}
|
||||
center
|
||||
position={[0, height > 0 ? height + 0.2 : 0.6, 0]}
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
background: '#18181b', // zinc-900
|
||||
@@ -77,15 +95,15 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={activeToolConfig.iconSrc}
|
||||
alt={activeToolConfig.label}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
<img
|
||||
alt={activeToolConfig.label}
|
||||
src={activeToolConfig.iconSrc}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))'
|
||||
}}
|
||||
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))',
|
||||
}}
|
||||
/>
|
||||
</Html>
|
||||
)}
|
||||
|
||||
@@ -237,20 +237,20 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
{/* Border line */}
|
||||
<line
|
||||
// @ts-expect-error R3F <line> element conflicts with SVG <line> type
|
||||
ref={lineRef}
|
||||
frustumCulled={false}
|
||||
renderOrder={10}
|
||||
raycast={() => {}}
|
||||
layers={EDITOR_LAYER}
|
||||
raycast={() => {}}
|
||||
ref={lineRef}
|
||||
renderOrder={10}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color={color}
|
||||
linewidth={2}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
transparent
|
||||
linewidth={2}
|
||||
opacity={0.8}
|
||||
transparent
|
||||
/>
|
||||
</line>
|
||||
|
||||
@@ -263,28 +263,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
return (
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
key={`vertex-${index}`}
|
||||
position={[x!, editY + height / 2, z!]}
|
||||
castShadow
|
||||
onPointerEnter={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredVertex(index)
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredVertex(null)
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
vertexIndex: index,
|
||||
initialPosition: [x!, z!],
|
||||
pointerId: e.pointerId,
|
||||
})
|
||||
}}
|
||||
key={`vertex-${index}`}
|
||||
layers={EDITOR_LAYER}
|
||||
onClick={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
@@ -296,6 +277,25 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
handleDeleteVertex(index)
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
vertexIndex: index,
|
||||
initialPosition: [x!, z!],
|
||||
pointerId: e.pointerId,
|
||||
})
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredVertex(index)
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredVertex(null)
|
||||
}}
|
||||
position={[x!, editY + height / 2, z!]}
|
||||
>
|
||||
<cylinderGeometry args={[radius, radius, height, 16]} />
|
||||
<meshStandardMaterial
|
||||
@@ -314,16 +314,11 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
return (
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
key={`midpoint-${index}`}
|
||||
position={[x!, editY + height / 2, z!]}
|
||||
onPointerEnter={(e) => {
|
||||
layers={EDITOR_LAYER}
|
||||
onClick={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
setHoveredMidpoint(index)
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredMidpoint(null)
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
@@ -339,16 +334,21 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
setHoveredMidpoint(null)
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.button !== 0) return
|
||||
onPointerEnter={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredMidpoint(index)
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredMidpoint(null)
|
||||
}}
|
||||
position={[x!, editY + height / 2, z!]}
|
||||
>
|
||||
<cylinderGeometry args={[radius, radius, height, 16]} />
|
||||
<meshStandardMaterial
|
||||
color={isHovered ? '#4ade80' : '#22c55e'}
|
||||
transparent
|
||||
opacity={isHovered ? 1 : 0.7}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)
|
||||
|
||||
@@ -29,14 +29,14 @@ export const SiteBoundaryEditor: React.FC = () => {
|
||||
[site, updateNode],
|
||||
)
|
||||
|
||||
if (!site || !site.polygon?.points || site.polygon.points.length < 3) return null
|
||||
if (!(site && site.polygon?.points) || site.polygon.points.length < 3) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
polygon={site.polygon.points}
|
||||
color="#10b981"
|
||||
onPolygonChange={handlePolygonChange}
|
||||
minVertices={3}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
polygon={site.polygon.points}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,15 +27,15 @@ export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }
|
||||
[slabId, updateNode, setSelection],
|
||||
)
|
||||
|
||||
if (!slab || !slab.polygon || slab.polygon.length < 3) return null
|
||||
if (!(slab && slab.polygon) || slab.polygon.length < 3) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
polygon={slab.polygon}
|
||||
color="#a3a3a3"
|
||||
onPolygonChange={handlePolygonChange}
|
||||
minVertices={3}
|
||||
levelId={resolveLevelId(slab, useScene.getState().nodes)}
|
||||
minVertices={3}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
polygon={slab.polygon}
|
||||
surfaceHeight={slab.elevation ?? 0.05}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -32,15 +32,15 @@ export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeInde
|
||||
[slabId, holeIndex, holes, updateNode, setSelection],
|
||||
)
|
||||
|
||||
if (!slab || !hole || hole.length < 3) return null
|
||||
if (!(slab && hole) || hole.length < 3) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
polygon={hole}
|
||||
color="#ef4444" // red for holes
|
||||
onPolygonChange={handlePolygonChange}
|
||||
color="#ef4444"
|
||||
levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes
|
||||
minVertices={3}
|
||||
levelId={resolveLevelId(slab, useScene.getState().nodes)}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
polygon={hole}
|
||||
surfaceHeight={slab.elevation ?? 0.05}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } from 'three'
|
||||
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
@@ -35,13 +35,13 @@ const calculateSnapPoint = (
|
||||
// Snap to 45° diagonal
|
||||
const diagonalLength = Math.min(absDx, absDy)
|
||||
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
||||
} else if (minDist === horizontalDist) {
|
||||
}
|
||||
if (minDist === horizontalDist) {
|
||||
// Snap to horizontal
|
||||
return [x, y1]
|
||||
} else {
|
||||
// Snap to vertical
|
||||
return [x1, y]
|
||||
}
|
||||
// Snap to vertical
|
||||
return [x1, y]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,12 +94,19 @@ export const SlabTool: React.FC = () => {
|
||||
|
||||
// Calculate snapped display position (bypass snap when Shift is held)
|
||||
const lastPoint = points[points.length - 1]
|
||||
const displayPoint = (shiftPressed.current || !lastPoint) ? gridPosition : calculateSnapPoint(lastPoint, gridPosition)
|
||||
const displayPoint =
|
||||
shiftPressed.current || !lastPoint
|
||||
? gridPosition
|
||||
: calculateSnapPoint(lastPoint, gridPosition)
|
||||
setSnappedCursorPosition(displayPoint)
|
||||
|
||||
// Play snap sound when the snapped position actually changes (only when drawing)
|
||||
if (points.length > 0 && previousSnappedPointRef.current &&
|
||||
(displayPoint[0] !== previousSnappedPointRef.current[0] || displayPoint[1] !== previousSnappedPointRef.current[1])) {
|
||||
if (
|
||||
points.length > 0 &&
|
||||
previousSnappedPointRef.current &&
|
||||
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
|
||||
displayPoint[1] !== previousSnappedPointRef.current[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
@@ -146,8 +153,12 @@ export const SlabTool: React.FC = () => {
|
||||
setPoints([])
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = true }
|
||||
const onKeyUp = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = false }
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftPressed.current = true
|
||||
}
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftPressed.current = false
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('keyup', onKeyUp)
|
||||
|
||||
@@ -168,7 +179,7 @@ export const SlabTool: React.FC = () => {
|
||||
|
||||
// Update line geometries when points change
|
||||
useEffect(() => {
|
||||
if (!mainLineRef.current || !closingLineRef.current) return
|
||||
if (!(mainLineRef.current && closingLineRef.current)) return
|
||||
|
||||
if (points.length === 0) {
|
||||
mainLineRef.current.visible = false
|
||||
@@ -261,20 +272,32 @@ export const SlabTool: React.FC = () => {
|
||||
|
||||
{/* Main line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={mainLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} />
|
||||
<lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
|
||||
</line>
|
||||
|
||||
{/* Closing line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={closingLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
linewidth={2}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={2}
|
||||
opacity={0.5}
|
||||
transparent
|
||||
/>
|
||||
@@ -282,7 +305,13 @@ export const SlabTool: React.FC = () => {
|
||||
|
||||
{/* Point markers */}
|
||||
{points.map(([x, z], index) => (
|
||||
<CursorSphere key={index} position={[x, levelY + Y_OFFSET + 0.01, z]} color="#818cf8" showTooltip={false} height={0} />
|
||||
<CursorSphere
|
||||
color="#818cf8"
|
||||
height={0}
|
||||
key={index}
|
||||
position={[x, levelY + Y_OFFSET + 0.01, z]}
|
||||
showTooltip={false}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
|
||||
@@ -61,7 +61,9 @@ export const ToolManager: React.FC = () => {
|
||||
|
||||
// Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)
|
||||
const showSlabBoundaryEditor =
|
||||
phase === 'structure' && mode === 'select' && selectedSlabId !== undefined &&
|
||||
phase === 'structure' &&
|
||||
mode === 'select' &&
|
||||
selectedSlabId !== undefined &&
|
||||
(!editingHole || editingHole.nodeId !== selectedSlabId)
|
||||
|
||||
// Show slab hole editor when editing a hole on the selected slab
|
||||
@@ -70,12 +72,16 @@ export const ToolManager: React.FC = () => {
|
||||
|
||||
// Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole)
|
||||
const showCeilingBoundaryEditor =
|
||||
phase === 'structure' && mode === 'select' && selectedCeilingId !== undefined &&
|
||||
phase === 'structure' &&
|
||||
mode === 'select' &&
|
||||
selectedCeilingId !== undefined &&
|
||||
(!editingHole || editingHole.nodeId !== selectedCeilingId)
|
||||
|
||||
// Show ceiling hole editor when editing a hole on the selected ceiling
|
||||
const showCeilingHoleEditor =
|
||||
selectedCeilingId !== undefined && editingHole !== null && editingHole.nodeId === selectedCeilingId
|
||||
selectedCeilingId !== undefined &&
|
||||
editingHole !== null &&
|
||||
editingHole.nodeId === selectedCeilingId
|
||||
|
||||
// Show zone boundary editor when in structure/select mode with a zone selected
|
||||
// Hide when editing a slab or ceiling to avoid overlapping handles
|
||||
@@ -97,7 +103,7 @@ export const ToolManager: React.FC = () => {
|
||||
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
|
||||
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
|
||||
{showSlabHoleEditor && selectedSlabId && editingHole && (
|
||||
<SlabHoleEditor slabId={selectedSlabId} holeIndex={editingHole.holeIndex} />
|
||||
<SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
|
||||
)}
|
||||
{showCeilingBoundaryEditor && selectedCeilingId && (
|
||||
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { DoubleSide, type Mesh, type Group, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
@@ -110,7 +110,7 @@ export const WallTool: React.FC = () => {
|
||||
let previousWallEnd: [number, number] | null = null
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current || !wallPreviewRef.current) return
|
||||
if (!(cursorRef.current && wallPreviewRef.current)) return
|
||||
|
||||
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])
|
||||
@@ -127,8 +127,10 @@ export const WallTool: React.FC = () => {
|
||||
|
||||
// Play snap sound only when the actual wall end position changes
|
||||
const currentWallEnd: [number, number] = [endingPoint.current.x, endingPoint.current.z]
|
||||
if (previousWallEnd &&
|
||||
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])) {
|
||||
if (
|
||||
previousWallEnd &&
|
||||
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
previousWallEnd = currentWallEnd
|
||||
@@ -196,18 +198,18 @@ export const WallTool: React.FC = () => {
|
||||
return (
|
||||
<group>
|
||||
{/* Cursor indicator */}
|
||||
<CursorSphere ref={cursorRef} />
|
||||
<CursorSphere ref={cursorRef} />
|
||||
|
||||
{/* Wall preview */}
|
||||
<mesh ref={wallPreviewRef} visible={false} renderOrder={1} layers={EDITOR_LAYER}>
|
||||
<mesh layers={EDITOR_LAYER} ref={wallPreviewRef} renderOrder={1} visible={false}>
|
||||
<shapeGeometry />
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
transparent
|
||||
opacity={0.5}
|
||||
side={DoubleSide}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.5}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef4444,
|
||||
color: 0xef_44_44,
|
||||
linewidth: 3,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
@@ -52,9 +52,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const meta = (typeof movingWindowNode.metadata === 'object' && movingWindowNode.metadata !== null)
|
||||
? movingWindowNode.metadata as Record<string, unknown>
|
||||
: {}
|
||||
const meta =
|
||||
typeof movingWindowNode.metadata === 'object' && movingWindowNode.metadata !== null
|
||||
? (movingWindowNode.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
|
||||
// Save original state (only used in move mode)
|
||||
@@ -106,7 +107,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
group.visible = true
|
||||
group.position.set(...worldPosition)
|
||||
group.rotation.y = cursorRotationY
|
||||
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
@@ -121,8 +122,11 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node, localX, localY,
|
||||
movingWindowNode.width, movingWindowNode.height,
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
)
|
||||
|
||||
const prevWallId = currentWallId
|
||||
@@ -140,13 +144,22 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
markWallDirty(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY,
|
||||
movingWindowNode.width, movingWindowNode.height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
@@ -165,8 +178,11 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node, localX, localY,
|
||||
movingWindowNode.width, movingWindowNode.height,
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
)
|
||||
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
@@ -184,13 +200,22 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
markWallDirty(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY,
|
||||
movingWindowNode.width, movingWindowNode.height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
@@ -208,13 +233,19 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node, localX, localY,
|
||||
movingWindowNode.width, movingWindowNode.height,
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY,
|
||||
movingWindowNode.width, movingWindowNode.height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
if (!valid) return
|
||||
@@ -331,7 +362,9 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
|
||||
return () => {
|
||||
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
|
||||
const current = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as WindowNode | undefined
|
||||
const current = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as
|
||||
| WindowNode
|
||||
| undefined
|
||||
const currentMeta = current?.metadata as Record<string, unknown> | undefined
|
||||
if (currentMeta?.isTransient) {
|
||||
if (isNew) {
|
||||
@@ -371,7 +404,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
|
||||
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { type AnyNodeId, type DoorNode, getScaledDimensions, type ItemNode, useScene, type WallNode, type WindowNode } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type DoorNode,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
|
||||
@@ -82,19 +90,19 @@ export function hasWallChildOverlap(
|
||||
const [w, h] = getScaledDimensions(item)
|
||||
childLeft = item.position[0] - w / 2
|
||||
childRight = item.position[0] + w / 2
|
||||
childBottom = item.position[1] // items store bottom Y
|
||||
childBottom = item.position[1] // items store bottom Y
|
||||
childTop = item.position[1] + h
|
||||
} else if (child.type === 'window') {
|
||||
const win = child as WindowNode
|
||||
childLeft = win.position[0] - win.width / 2
|
||||
childRight = win.position[0] + win.width / 2
|
||||
childBottom = win.position[1] - win.height / 2 // windows store center Y
|
||||
childBottom = win.position[1] - win.height / 2 // windows store center Y
|
||||
childTop = win.position[1] + win.height / 2
|
||||
} else if (child.type === 'door') {
|
||||
const door = child as DoorNode
|
||||
childLeft = door.position[0] - door.width / 2
|
||||
childRight = door.position[0] + door.width / 2
|
||||
childBottom = door.position[1] - door.height / 2 // doors store center Y
|
||||
childBottom = door.position[1] - door.height / 2 // doors store center Y
|
||||
childTop = door.position[1] + door.height / 2
|
||||
} else {
|
||||
continue
|
||||
|
||||
@@ -11,6 +11,8 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
@@ -19,12 +21,10 @@ import {
|
||||
snapToHalf,
|
||||
} from '../item/placement-math'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
|
||||
// Shared edge material — reuse across renders, just toggle color
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef4444, // red-500 default (invalid)
|
||||
color: 0xef_44_44, // red-500 default (invalid)
|
||||
linewidth: 3,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
@@ -81,7 +81,7 @@ export const WindowTool: React.FC = () => {
|
||||
group.visible = true
|
||||
group.position.set(...worldPosition)
|
||||
group.rotation.y = cursorRotationY
|
||||
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
@@ -120,7 +120,13 @@ export const WindowTool: React.FC = () => {
|
||||
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
@@ -155,12 +161,22 @@ export const WindowTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY, width, height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
width,
|
||||
height,
|
||||
draftRef.current?.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
@@ -179,12 +195,18 @@ export const WindowTool: React.FC = () => {
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node, localX, localY,
|
||||
draftRef.current.width, draftRef.current.height,
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
)
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY,
|
||||
draftRef.current.width, draftRef.current.height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
draftRef.current.id,
|
||||
)
|
||||
if (!valid) return
|
||||
@@ -270,7 +292,12 @@ export const WindowTool: React.FC = () => {
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments ref={edgesRef} geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
|
||||
<lineSegments
|
||||
geometry={edgesGeo}
|
||||
layers={EDITOR_LAYER}
|
||||
material={edgeMaterial}
|
||||
ref={edgesRef}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,17 +23,17 @@ export const ZoneBoundaryEditor: React.FC<ZoneBoundaryEditorProps> = ({ zoneId }
|
||||
[zoneId, updateNode],
|
||||
)
|
||||
|
||||
if (!zone || !zone.polygon || zone.polygon.length < 3) return null
|
||||
if (!(zone && zone.polygon) || zone.polygon.length < 3) return null
|
||||
|
||||
const zoneColor = zone.color || '#3b82f6'
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
polygon={zone.polygon}
|
||||
color={zoneColor}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
minVertices={3}
|
||||
levelId={resolveLevelId(zone, useScene.getState().nodes)}
|
||||
minVertices={3}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
polygon={zone.polygon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,222 +1,212 @@
|
||||
import { emitter, type GridEvent, useScene, ZoneNode, type LevelNode } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } from "three";
|
||||
import { EDITOR_LAYER } from "./../../../lib/constants";
|
||||
import useEditor from "./../../../store/use-editor";
|
||||
import { CursorSphere } from "../shared/cursor-sphere";
|
||||
import { PALETTE_COLORS } from "./../../../components/ui/primitives/color-dot";
|
||||
import { emitter, type GridEvent, type LevelNode, useScene, ZoneNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
||||
import { PALETTE_COLORS } from './../../../components/ui/primitives/color-dot'
|
||||
import { EDITOR_LAYER } from './../../../lib/constants'
|
||||
import useEditor from './../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
const Y_OFFSET = 0.02;
|
||||
const Y_OFFSET = 0.02
|
||||
|
||||
/**
|
||||
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
|
||||
*/
|
||||
const calculateSnapPoint = (
|
||||
lastPoint: [number, number],
|
||||
currentPoint: [number, number]
|
||||
currentPoint: [number, number],
|
||||
): [number, number] => {
|
||||
const [x1, y1] = lastPoint;
|
||||
const [x, y] = currentPoint;
|
||||
const [x1, y1] = lastPoint
|
||||
const [x, y] = currentPoint
|
||||
|
||||
const dx = x - x1;
|
||||
const dy = y - y1;
|
||||
const absDx = Math.abs(dx);
|
||||
const absDy = Math.abs(dy);
|
||||
const dx = x - x1
|
||||
const dy = y - y1
|
||||
const absDx = Math.abs(dx)
|
||||
const absDy = Math.abs(dy)
|
||||
|
||||
// Calculate distances to horizontal, vertical, and diagonal lines
|
||||
const horizontalDist = absDy;
|
||||
const verticalDist = absDx;
|
||||
const diagonalDist = Math.abs(absDx - absDy);
|
||||
const horizontalDist = absDy
|
||||
const verticalDist = absDx
|
||||
const diagonalDist = Math.abs(absDx - absDy)
|
||||
|
||||
// Find the minimum distance to determine which axis to snap to
|
||||
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist);
|
||||
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
|
||||
|
||||
if (minDist === diagonalDist) {
|
||||
// Snap to 45° diagonal
|
||||
const diagonalLength = Math.min(absDx, absDy);
|
||||
return [
|
||||
x1 + Math.sign(dx) * diagonalLength,
|
||||
y1 + Math.sign(dy) * diagonalLength,
|
||||
];
|
||||
} else if (minDist === horizontalDist) {
|
||||
// Snap to horizontal
|
||||
return [x, y1];
|
||||
} else {
|
||||
// Snap to vertical
|
||||
return [x1, y];
|
||||
const diagonalLength = Math.min(absDx, absDy)
|
||||
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
||||
}
|
||||
};
|
||||
if (minDist === horizontalDist) {
|
||||
// Snap to horizontal
|
||||
return [x, y1]
|
||||
}
|
||||
// Snap to vertical
|
||||
return [x1, y]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a zone with the given polygon points
|
||||
*/
|
||||
const commitZoneDrawing = (
|
||||
levelId: LevelNode["id"],
|
||||
points: Array<[number, number]>
|
||||
) => {
|
||||
const { createNode, nodes } = useScene.getState();
|
||||
const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>) => {
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
|
||||
// Count existing zones for naming and color cycling
|
||||
const zoneCount = Object.values(nodes).filter((n) => n.type === "zone").length;
|
||||
const name = `Zone ${zoneCount + 1}`;
|
||||
const zoneCount = Object.values(nodes).filter((n) => n.type === 'zone').length
|
||||
const name = `Zone ${zoneCount + 1}`
|
||||
|
||||
// Cycle through colors
|
||||
const color = PALETTE_COLORS[zoneCount % PALETTE_COLORS.length];
|
||||
const color = PALETTE_COLORS[zoneCount % PALETTE_COLORS.length]
|
||||
|
||||
const zone = ZoneNode.parse({
|
||||
name,
|
||||
polygon: points,
|
||||
color,
|
||||
});
|
||||
})
|
||||
|
||||
createNode(zone, levelId);
|
||||
createNode(zone, levelId)
|
||||
|
||||
// Select the newly created zone
|
||||
useViewer.getState().setSelection({ zoneId: zone.id });
|
||||
};
|
||||
useViewer.getState().setSelection({ zoneId: zone.id })
|
||||
}
|
||||
|
||||
type PreviewState = {
|
||||
points: Array<[number, number]>;
|
||||
cursorPoint: [number, number] | null;
|
||||
levelY: number;
|
||||
};
|
||||
points: Array<[number, number]>
|
||||
cursorPoint: [number, number] | null
|
||||
levelY: number
|
||||
}
|
||||
|
||||
// Helper to validate point values (no NaN or Infinity)
|
||||
const isValidPoint = (
|
||||
pt: [number, number] | null | undefined
|
||||
): pt is [number, number] => {
|
||||
if (!pt) return false;
|
||||
return Number.isFinite(pt[0]) && Number.isFinite(pt[1]);
|
||||
};
|
||||
const isValidPoint = (pt: [number, number] | null | undefined): pt is [number, number] => {
|
||||
if (!pt) return false
|
||||
return Number.isFinite(pt[0]) && Number.isFinite(pt[1])
|
||||
}
|
||||
|
||||
export const ZoneTool: React.FC = () => {
|
||||
const cursorRef = useRef<Group>(null);
|
||||
const mainLineRef = useRef<Line>(null!);
|
||||
const closingLineRef = useRef<Line>(null!);
|
||||
const pointsRef = useRef<Array<[number, number]>>([]);
|
||||
const levelYRef = useRef(0); // Track current level Y position
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId);
|
||||
const setTool = useEditor((state) => state.setTool);
|
||||
const cursorRef = useRef<Group>(null)
|
||||
const mainLineRef = useRef<Line>(null!)
|
||||
const closingLineRef = useRef<Line>(null!)
|
||||
const pointsRef = useRef<Array<[number, number]>>([])
|
||||
const levelYRef = useRef(0) // Track current level Y position
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||
const setTool = useEditor((state) => state.setTool)
|
||||
|
||||
// Preview state for reactive rendering (for shape and point markers)
|
||||
const [preview, setPreview] = useState<PreviewState>({
|
||||
points: [],
|
||||
cursorPoint: null,
|
||||
levelY: 0,
|
||||
});
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLevelId) return;
|
||||
if (!currentLevelId) return
|
||||
|
||||
let cursorPosition: [number, number] = [0, 0];
|
||||
let cursorPosition: [number, number] = [0, 0]
|
||||
|
||||
// Initialize line geometries
|
||||
mainLineRef.current.geometry = new BufferGeometry();
|
||||
closingLineRef.current.geometry = new BufferGeometry();
|
||||
mainLineRef.current.geometry = new BufferGeometry()
|
||||
closingLineRef.current.geometry = new BufferGeometry()
|
||||
|
||||
const updateLines = () => {
|
||||
const points = pointsRef.current;
|
||||
const y = levelYRef.current + Y_OFFSET;
|
||||
const points = pointsRef.current
|
||||
const y = levelYRef.current + Y_OFFSET
|
||||
|
||||
if (points.length === 0) {
|
||||
mainLineRef.current.visible = false;
|
||||
closingLineRef.current.visible = false;
|
||||
return;
|
||||
mainLineRef.current.visible = false
|
||||
closingLineRef.current.visible = false
|
||||
return
|
||||
}
|
||||
|
||||
// Build main line points
|
||||
const linePoints: Vector3[] = points.map(
|
||||
([x, z]) => new Vector3(x, y, z)
|
||||
);
|
||||
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z))
|
||||
|
||||
// Add cursor point
|
||||
const lastPoint = points[points.length - 1];
|
||||
const lastPoint = points[points.length - 1]
|
||||
if (lastPoint) {
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition)
|
||||
if (isValidPoint(snapped)) {
|
||||
linePoints.push(new Vector3(snapped[0], y, snapped[1]));
|
||||
linePoints.push(new Vector3(snapped[0], y, snapped[1]))
|
||||
}
|
||||
}
|
||||
|
||||
// Update main line geometry
|
||||
if (linePoints.length >= 2) {
|
||||
mainLineRef.current.geometry.dispose();
|
||||
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints);
|
||||
mainLineRef.current.visible = true;
|
||||
mainLineRef.current.geometry.dispose()
|
||||
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
|
||||
mainLineRef.current.visible = true
|
||||
} else {
|
||||
mainLineRef.current.visible = false;
|
||||
mainLineRef.current.visible = false
|
||||
}
|
||||
|
||||
// Update closing line (from cursor back to first point)
|
||||
const firstPoint = points[0];
|
||||
const firstPoint = points[0]
|
||||
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition)
|
||||
if (isValidPoint(snapped)) {
|
||||
const closingPoints = [
|
||||
new Vector3(snapped[0], y, snapped[1]),
|
||||
new Vector3(firstPoint[0], y, firstPoint[1]),
|
||||
];
|
||||
closingLineRef.current.geometry.dispose();
|
||||
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints);
|
||||
closingLineRef.current.visible = true;
|
||||
]
|
||||
closingLineRef.current.geometry.dispose()
|
||||
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
|
||||
closingLineRef.current.visible = true
|
||||
}
|
||||
} else {
|
||||
closingLineRef.current.visible = false;
|
||||
closingLineRef.current.visible = false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const updatePreview = () => {
|
||||
const points = pointsRef.current;
|
||||
const lastPoint = points[points.length - 1];
|
||||
const points = pointsRef.current
|
||||
const lastPoint = points[points.length - 1]
|
||||
|
||||
let cursorPt: [number, number] | null = null;
|
||||
let cursorPt: [number, number] | null = null
|
||||
if (lastPoint) {
|
||||
cursorPt = calculateSnapPoint(lastPoint, cursorPosition);
|
||||
cursorPt = calculateSnapPoint(lastPoint, cursorPosition)
|
||||
} else if (points.length === 0) {
|
||||
cursorPt = cursorPosition;
|
||||
cursorPt = cursorPosition
|
||||
}
|
||||
|
||||
setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current });
|
||||
updateLines();
|
||||
};
|
||||
setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current })
|
||||
updateLines()
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current) return;
|
||||
if (!cursorRef.current) return
|
||||
|
||||
// Snap to 0.5 grid
|
||||
const gridX = Math.round(event.position[0] * 2) / 2;
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2;
|
||||
cursorPosition = [gridX, gridZ];
|
||||
levelYRef.current = event.position[1];
|
||||
const gridX = Math.round(event.position[0] * 2) / 2
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
cursorPosition = [gridX, gridZ]
|
||||
levelYRef.current = event.position[1]
|
||||
|
||||
// If we have points, snap to axis from last point
|
||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
|
||||
if (lastPoint) {
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
||||
cursorRef.current.position.set(snapped[0], event.position[1], snapped[1]);
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition)
|
||||
cursorRef.current.position.set(snapped[0], event.position[1], snapped[1])
|
||||
} else {
|
||||
cursorRef.current.position.set(gridX, event.position[1], gridZ);
|
||||
cursorRef.current.position.set(gridX, event.position[1], gridZ)
|
||||
}
|
||||
|
||||
updatePreview();
|
||||
};
|
||||
updatePreview()
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (!currentLevelId) return;
|
||||
if (!currentLevelId) return
|
||||
|
||||
const gridX = Math.round(event.position[0] * 2) / 2;
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2;
|
||||
let clickPoint: [number, number] = [gridX, gridZ];
|
||||
const gridX = Math.round(event.position[0] * 2) / 2
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
let clickPoint: [number, number] = [gridX, gridZ]
|
||||
|
||||
// Snap to axis from last point
|
||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
|
||||
if (lastPoint) {
|
||||
clickPoint = calculateSnapPoint(lastPoint, clickPoint);
|
||||
clickPoint = calculateSnapPoint(lastPoint, clickPoint)
|
||||
}
|
||||
|
||||
// Check if clicking on the first point to close the shape
|
||||
const firstPoint = pointsRef.current[0];
|
||||
const firstPoint = pointsRef.current[0]
|
||||
if (
|
||||
pointsRef.current.length >= 3 &&
|
||||
firstPoint &&
|
||||
@@ -224,80 +214,80 @@ export const ZoneTool: React.FC = () => {
|
||||
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
||||
) {
|
||||
// Create the zone
|
||||
commitZoneDrawing(currentLevelId, pointsRef.current);
|
||||
commitZoneDrawing(currentLevelId, pointsRef.current)
|
||||
|
||||
// Reset state
|
||||
pointsRef.current = [];
|
||||
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
|
||||
mainLineRef.current.visible = false;
|
||||
closingLineRef.current.visible = false;
|
||||
pointsRef.current = []
|
||||
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current })
|
||||
mainLineRef.current.visible = false
|
||||
closingLineRef.current.visible = false
|
||||
} else {
|
||||
// Add point to polygon
|
||||
pointsRef.current = [...pointsRef.current, clickPoint];
|
||||
updatePreview();
|
||||
pointsRef.current = [...pointsRef.current, clickPoint]
|
||||
updatePreview()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const onGridDoubleClick = (_event: GridEvent) => {
|
||||
if (!currentLevelId) return;
|
||||
if (!currentLevelId) return
|
||||
|
||||
// Need at least 3 points to form a polygon
|
||||
if (pointsRef.current.length >= 3) {
|
||||
commitZoneDrawing(currentLevelId, pointsRef.current);
|
||||
commitZoneDrawing(currentLevelId, pointsRef.current)
|
||||
|
||||
// Reset state
|
||||
pointsRef.current = [];
|
||||
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
|
||||
mainLineRef.current.visible = false;
|
||||
closingLineRef.current.visible = false;
|
||||
pointsRef.current = []
|
||||
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current })
|
||||
mainLineRef.current.visible = false
|
||||
closingLineRef.current.visible = false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Subscribe to events
|
||||
emitter.on("grid:move", onGridMove);
|
||||
emitter.on("grid:click", onGridClick);
|
||||
emitter.on("grid:double-click", onGridDoubleClick);
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('grid:double-click', onGridDoubleClick)
|
||||
|
||||
return () => {
|
||||
emitter.off("grid:move", onGridMove);
|
||||
emitter.off("grid:click", onGridClick);
|
||||
emitter.off("grid:double-click", onGridDoubleClick);
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('grid:double-click', onGridDoubleClick)
|
||||
|
||||
// Reset state on unmount
|
||||
pointsRef.current = [];
|
||||
};
|
||||
}, [currentLevelId, setTool]);
|
||||
pointsRef.current = []
|
||||
}
|
||||
}, [currentLevelId, setTool])
|
||||
|
||||
const { points, cursorPoint, levelY } = preview;
|
||||
const { points, cursorPoint, levelY } = preview
|
||||
|
||||
// Create preview shape when we have 3+ points
|
||||
const previewShape = useMemo(() => {
|
||||
if (points.length < 3) return null;
|
||||
if (points.length < 3) return null
|
||||
|
||||
const allPoints = [...points];
|
||||
const allPoints = [...points]
|
||||
if (isValidPoint(cursorPoint)) {
|
||||
allPoints.push(cursorPoint);
|
||||
allPoints.push(cursorPoint)
|
||||
}
|
||||
|
||||
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
|
||||
// - Shape X -> World X
|
||||
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
|
||||
const firstPt = allPoints[0];
|
||||
if (!isValidPoint(firstPt)) return null;
|
||||
const firstPt = allPoints[0]
|
||||
if (!isValidPoint(firstPt)) return null
|
||||
|
||||
const shape = new Shape();
|
||||
shape.moveTo(firstPt[0], -firstPt[1]);
|
||||
const shape = new Shape()
|
||||
shape.moveTo(firstPt[0], -firstPt[1])
|
||||
|
||||
for (let i = 1; i < allPoints.length; i++) {
|
||||
const pt = allPoints[i];
|
||||
const pt = allPoints[i]
|
||||
if (isValidPoint(pt)) {
|
||||
shape.lineTo(pt[0], -pt[1]);
|
||||
shape.lineTo(pt[0], -pt[1])
|
||||
}
|
||||
}
|
||||
shape.closePath();
|
||||
shape.closePath()
|
||||
|
||||
return shape;
|
||||
}, [points, cursorPoint]);
|
||||
return shape
|
||||
}, [points, cursorPoint])
|
||||
|
||||
return (
|
||||
<group>
|
||||
@@ -325,25 +315,32 @@ export const ZoneTool: React.FC = () => {
|
||||
|
||||
{/* Main line - uses native line element with TSL-compatible material */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={mainLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
linewidth={3}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
/>
|
||||
<lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
|
||||
</line>
|
||||
|
||||
{/* Closing line - uses native line element with TSL-compatible material */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={closingLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
linewidth={2}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={2}
|
||||
opacity={0.5}
|
||||
transparent
|
||||
/>
|
||||
@@ -352,9 +349,15 @@ export const ZoneTool: React.FC = () => {
|
||||
{/* Point markers */}
|
||||
{points.map(([x, z], index) =>
|
||||
isValidPoint([x, z]) ? (
|
||||
<CursorSphere key={index} position={[x, levelY + Y_OFFSET + 0.01, z]} color="#818cf8" showTooltip={false} height={0} />
|
||||
) : null
|
||||
<CursorSphere
|
||||
color="#818cf8"
|
||||
height={0}
|
||||
key={index}
|
||||
position={[x, levelY + Y_OFFSET + 0.01, z]}
|
||||
showTooltip={false}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,47 +1,44 @@
|
||||
import * as React from "react";
|
||||
import { Button } from "./../../../components/ui/primitives/button";
|
||||
import * as React from 'react'
|
||||
import { Button } from './../../../components/ui/primitives/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "./../../../components/ui/primitives/tooltip";
|
||||
import { cn } from "./../../../lib/utils";
|
||||
} from './../../../components/ui/primitives/tooltip'
|
||||
import { cn } from './../../../lib/utils'
|
||||
|
||||
interface ActionButtonProps extends React.ComponentProps<typeof Button> {
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
isActive?: boolean;
|
||||
tooltipContent?: React.ReactNode;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
label: string
|
||||
shortcut?: string
|
||||
isActive?: boolean
|
||||
tooltipContent?: React.ReactNode
|
||||
tooltipSide?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}
|
||||
|
||||
export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
|
||||
(
|
||||
{ className, children, label, shortcut, isActive, tooltipContent, tooltipSide, ...props },
|
||||
ref
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn('relative h-11 w-11 transition-all', className)}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-11 w-11 transition-all",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center transition-transform",
|
||||
shortcut && "-translate-x-0.5 -translate-y-0.5"
|
||||
'flex h-full w-full items-center justify-center transition-transform',
|
||||
shortcut && '-translate-x-0.5 -translate-y-0.5',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{shortcut && (
|
||||
<div className="absolute bottom-1 right-1 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
|
||||
<span className="block font-mono text-[9px] font-medium leading-none text-muted-foreground/70">
|
||||
<div className="absolute right-1 bottom-1 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
|
||||
<span className="block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none">
|
||||
{shortcut}
|
||||
</span>
|
||||
</div>
|
||||
@@ -56,7 +53,7 @@ export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProp
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
);
|
||||
ActionButton.displayName = "ActionButton";
|
||||
)
|
||||
},
|
||||
)
|
||||
ActionButton.displayName = 'ActionButton'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { emitter } from '@pascal-app/core'
|
||||
import Image from 'next/image'
|
||||
import { ActionButton } from "./action-button";
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
export function CameraActions() {
|
||||
const goToTopView = () => {
|
||||
@@ -21,15 +21,15 @@ export function CameraActions() {
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Orbit CCW */}
|
||||
<ActionButton
|
||||
label="Orbit Left"
|
||||
className="group hover:bg-white/5"
|
||||
label="Orbit Left"
|
||||
onClick={orbitCCW}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Image
|
||||
alt="Orbit Left"
|
||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100 -scale-x-100"
|
||||
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
height={28}
|
||||
src="/icons/rotate.png"
|
||||
width={28}
|
||||
@@ -38,8 +38,8 @@ export function CameraActions() {
|
||||
|
||||
{/* Orbit CW */}
|
||||
<ActionButton
|
||||
label="Orbit Right"
|
||||
className="group hover:bg-white/5"
|
||||
label="Orbit Right"
|
||||
onClick={orbitCW}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -55,8 +55,8 @@ export function CameraActions() {
|
||||
|
||||
{/* Top View */}
|
||||
<ActionButton
|
||||
label="Top View"
|
||||
className="group hover:bg-white/5"
|
||||
label="Top View"
|
||||
onClick={goToTopView}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,55 +1,54 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import Image from "next/image";
|
||||
import { ActionButton } from "./action-button";
|
||||
import { Pencil, Trash2, type LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./../../../lib/utils";
|
||||
import useEditor, { Mode, Phase } from "./../../../store/use-editor";
|
||||
import { type LucideIcon, Pencil, Trash2 } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import useEditor, { type Mode, type Phase } from './../../../store/use-editor'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
type ModeConfig = {
|
||||
id: Mode;
|
||||
icon?: LucideIcon;
|
||||
imageSrc?: string;
|
||||
label: string;
|
||||
shortcut: string;
|
||||
color: string;
|
||||
activeColor: string;
|
||||
};
|
||||
id: Mode
|
||||
icon?: LucideIcon
|
||||
imageSrc?: string
|
||||
label: string
|
||||
shortcut: string
|
||||
color: string
|
||||
activeColor: string
|
||||
}
|
||||
|
||||
// All available control modes
|
||||
const allModes: ModeConfig[] = [
|
||||
{
|
||||
id: "select",
|
||||
imageSrc: "/icons/select.png",
|
||||
label: "Select",
|
||||
shortcut: "V",
|
||||
color: "hover:bg-blue-500/20 hover:text-blue-400",
|
||||
activeColor: "bg-blue-500/20 text-blue-400",
|
||||
id: 'select',
|
||||
imageSrc: '/icons/select.png',
|
||||
label: 'Select',
|
||||
shortcut: 'V',
|
||||
color: 'hover:bg-blue-500/20 hover:text-blue-400',
|
||||
activeColor: 'bg-blue-500/20 text-blue-400',
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
id: 'edit',
|
||||
icon: Pencil,
|
||||
label: "Edit",
|
||||
shortcut: "E",
|
||||
color: "hover:bg-orange-500/20 hover:text-orange-400",
|
||||
activeColor: "bg-orange-500/20 text-orange-400",
|
||||
label: 'Edit',
|
||||
shortcut: 'E',
|
||||
color: 'hover:bg-orange-500/20 hover:text-orange-400',
|
||||
activeColor: 'bg-orange-500/20 text-orange-400',
|
||||
},
|
||||
{
|
||||
id: "build",
|
||||
imageSrc: "/icons/build.png",
|
||||
label: "Build",
|
||||
shortcut: "B",
|
||||
color: "hover:bg-green-500/20 hover:text-green-400",
|
||||
activeColor: "bg-green-500/20 text-green-400",
|
||||
id: 'build',
|
||||
imageSrc: '/icons/build.png',
|
||||
label: 'Build',
|
||||
shortcut: 'B',
|
||||
color: 'hover:bg-green-500/20 hover:text-green-400',
|
||||
activeColor: 'bg-green-500/20 text-green-400',
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
id: 'delete',
|
||||
icon: Trash2,
|
||||
label: "Delete",
|
||||
shortcut: "D",
|
||||
color: "hover:bg-red-500/20 hover:text-red-400",
|
||||
activeColor: "bg-red-500/20 text-red-400",
|
||||
label: 'Delete',
|
||||
shortcut: 'D',
|
||||
color: 'hover:bg-red-500/20 hover:text-red-400',
|
||||
activeColor: 'bg-red-500/20 text-red-400',
|
||||
},
|
||||
// {
|
||||
// id: 'painting',
|
||||
@@ -67,49 +66,47 @@ const allModes: ModeConfig[] = [
|
||||
// color: 'hover:bg-purple-500/20 hover:text-purple-400',
|
||||
// activeColor: 'bg-purple-500/20 text-purple-400',
|
||||
// },
|
||||
];
|
||||
]
|
||||
|
||||
// Define which modes are available in each editor mode
|
||||
const modesByPhase: Record<Phase, Mode[]> = {
|
||||
site: ["select", "edit"],
|
||||
structure: ["select", "delete", "build"],
|
||||
furnish: ["select", "delete", "build"],
|
||||
};
|
||||
site: ['select', 'edit'],
|
||||
structure: ['select', 'delete', 'build'],
|
||||
furnish: ['select', 'delete', 'build'],
|
||||
}
|
||||
|
||||
export function ControlModes() {
|
||||
const mode = useEditor((state) => state.mode);
|
||||
const phase = useEditor((state) => state.phase);
|
||||
const setMode = useEditor((state) => state.setMode);
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const phase = useEditor((state) => state.phase)
|
||||
const setMode = useEditor((state) => state.setMode)
|
||||
|
||||
const availableModeIds = modesByPhase[phase];
|
||||
const availableModes = allModes.filter((m) =>
|
||||
availableModeIds.includes(m.id)
|
||||
);
|
||||
const availableModeIds = modesByPhase[phase]
|
||||
const availableModes = allModes.filter((m) => availableModeIds.includes(m.id))
|
||||
|
||||
const handleModeClick = (mode: Mode) => {
|
||||
setMode(mode);
|
||||
};
|
||||
setMode(mode)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{availableModes.map((m) => {
|
||||
const Icon = m.icon;
|
||||
const isActive = mode === m.id;
|
||||
const isImageMode = Boolean(m.imageSrc);
|
||||
const Icon = m.icon
|
||||
const isActive = mode === m.id
|
||||
const isImageMode = Boolean(m.imageSrc)
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
className={cn(
|
||||
'text-muted-foreground',
|
||||
!(isImageMode || isActive) && m.color,
|
||||
!isImageMode && isActive && m.activeColor,
|
||||
isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
|
||||
isImageMode && !isActive && 'hover:bg-white/5',
|
||||
)}
|
||||
key={m.id}
|
||||
label={m.label}
|
||||
shortcut={m.shortcut}
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
!isImageMode && !isActive && m.color,
|
||||
!isImageMode && isActive && m.activeColor,
|
||||
isImageMode && isActive && "bg-white/10 hover:bg-white/10",
|
||||
isImageMode && !isActive && "hover:bg-white/5"
|
||||
)}
|
||||
onClick={() => handleModeClick(m.id)}
|
||||
shortcut={m.shortcut}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -117,9 +114,9 @@ export function ControlModes() {
|
||||
<Image
|
||||
alt={m.label}
|
||||
className={cn(
|
||||
"h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200",
|
||||
!isActive && "opacity-60 grayscale",
|
||||
isActive && "opacity-100 grayscale-0"
|
||||
'h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200',
|
||||
!isActive && 'opacity-60 grayscale',
|
||||
isActive && 'opacity-100 grayscale-0',
|
||||
)}
|
||||
height={28}
|
||||
src={m.imageSrc}
|
||||
@@ -129,8 +126,8 @@ export function ControlModes() {
|
||||
Icon && <Icon className="h-5 w-5" />
|
||||
)}
|
||||
</ActionButton>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,89 +1,86 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import NextImage from "next/image";
|
||||
import { ActionButton } from "./action-button";
|
||||
|
||||
import { cn } from "./../../../lib/utils";
|
||||
import useEditor, { CatalogCategory } from "./../../../store/use-editor";
|
||||
import NextImage from 'next/image'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
export type FurnishToolConfig = {
|
||||
id: "item";
|
||||
iconSrc: string;
|
||||
label: string;
|
||||
catalogCategory: CatalogCategory;
|
||||
};
|
||||
id: 'item'
|
||||
iconSrc: string
|
||||
label: string
|
||||
catalogCategory: CatalogCategory
|
||||
}
|
||||
|
||||
// Furnish mode tools: furniture, appliances, decoration (painting is now a control mode)
|
||||
export const furnishTools: FurnishToolConfig[] = [
|
||||
{
|
||||
id: "item",
|
||||
iconSrc: "/icons/couch.png",
|
||||
label: "Furniture",
|
||||
catalogCategory: "furniture",
|
||||
id: 'item',
|
||||
iconSrc: '/icons/couch.png',
|
||||
label: 'Furniture',
|
||||
catalogCategory: 'furniture',
|
||||
},
|
||||
{
|
||||
id: "item",
|
||||
iconSrc: "/icons/appliance.png",
|
||||
label: "Appliance",
|
||||
catalogCategory: "appliance",
|
||||
id: 'item',
|
||||
iconSrc: '/icons/appliance.png',
|
||||
label: 'Appliance',
|
||||
catalogCategory: 'appliance',
|
||||
},
|
||||
{
|
||||
id: "item",
|
||||
iconSrc: "/icons/kitchen.png",
|
||||
label: "Kitchen",
|
||||
catalogCategory: "kitchen",
|
||||
id: 'item',
|
||||
iconSrc: '/icons/kitchen.png',
|
||||
label: 'Kitchen',
|
||||
catalogCategory: 'kitchen',
|
||||
},
|
||||
{
|
||||
id: "item",
|
||||
iconSrc: "/icons/bathroom.png",
|
||||
label: "Bathroom",
|
||||
catalogCategory: "bathroom",
|
||||
id: 'item',
|
||||
iconSrc: '/icons/bathroom.png',
|
||||
label: 'Bathroom',
|
||||
catalogCategory: 'bathroom',
|
||||
},
|
||||
{
|
||||
id: "item",
|
||||
iconSrc: "/icons/tree.png",
|
||||
label: "Outdoor",
|
||||
catalogCategory: "outdoor",
|
||||
id: 'item',
|
||||
iconSrc: '/icons/tree.png',
|
||||
label: 'Outdoor',
|
||||
catalogCategory: 'outdoor',
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
export function FurnishTools() {
|
||||
const mode = useEditor((state) => state.mode);
|
||||
const activeTool = useEditor((state) => state.tool);
|
||||
const setActiveTool = useEditor((state) => state.setTool);
|
||||
const setMode = useEditor((state) => state.setMode);
|
||||
const catalogCategory = useEditor((state) => state.catalogCategory);
|
||||
const setCatalogCategory = useEditor((state) => state.setCatalogCategory);
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const activeTool = useEditor((state) => state.tool)
|
||||
const setActiveTool = useEditor((state) => state.setTool)
|
||||
const setMode = useEditor((state) => state.setMode)
|
||||
const catalogCategory = useEditor((state) => state.catalogCategory)
|
||||
const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
|
||||
|
||||
const hasActiveTool = furnishTools.some((tool) =>
|
||||
mode === "build" &&
|
||||
activeTool === "item" &&
|
||||
catalogCategory === tool.catalogCategory
|
||||
);
|
||||
const hasActiveTool = furnishTools.some(
|
||||
(tool) => mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-1">
|
||||
{furnishTools.map((tool, index) => {
|
||||
// For item tools with catalog category, check both tool and category match
|
||||
const isActive =
|
||||
mode === "build" &&
|
||||
activeTool === "item" &&
|
||||
catalogCategory === tool.catalogCategory;
|
||||
mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
className={cn(
|
||||
'rounded-lg duration-300',
|
||||
isActive
|
||||
? 'z-10 scale-110 bg-black/40 hover:bg-black/40'
|
||||
: 'scale-95 bg-transparent opacity-60 grayscale hover:bg-black/20 hover:opacity-100 hover:grayscale-0',
|
||||
)}
|
||||
key={`${tool.id}-${tool.catalogCategory ?? index}`}
|
||||
label={tool.label}
|
||||
className={cn(
|
||||
"rounded-lg duration-300",
|
||||
isActive ? "bg-black/40 hover:bg-black/40 scale-110 z-10" : "bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isActive) {
|
||||
setCatalogCategory(tool.catalogCategory);
|
||||
setActiveTool("item");
|
||||
if (mode !== "build") {
|
||||
setMode("build");
|
||||
setCatalogCategory(tool.catalogCategory)
|
||||
setActiveTool('item')
|
||||
if (mode !== 'build') {
|
||||
setMode('build')
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -98,8 +95,8 @@ export function FurnishTools() {
|
||||
width={28}
|
||||
/>
|
||||
</ActionButton>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,54 +1,43 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { TooltipProvider } from "./../../../components/ui/primitives/tooltip";
|
||||
import { cn } from "./../../../lib/utils";
|
||||
|
||||
import { CameraActions } from "./camera-actions";
|
||||
import { ControlModes } from "./control-modes";
|
||||
import { StructureTools } from "./structure-tools";
|
||||
import useEditor from "./../../../store/use-editor";
|
||||
import { useReducedMotion } from "./../../../hooks/use-reduced-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { ItemCatalog } from "../item-catalog/item-catalog";
|
||||
import { FurnishTools } from "./furnish-tools";
|
||||
import { ViewToggles } from "./view-toggles";
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
|
||||
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import useEditor from './../../../store/use-editor'
|
||||
import { ItemCatalog } from '../item-catalog/item-catalog'
|
||||
import { CameraActions } from './camera-actions'
|
||||
import { ControlModes } from './control-modes'
|
||||
import { FurnishTools } from './furnish-tools'
|
||||
import { StructureTools } from './structure-tools'
|
||||
import { ViewToggles } from './view-toggles'
|
||||
|
||||
export function ActionMenu({ className }: { className?: string }) {
|
||||
const phase = useEditor((state) => state.phase);
|
||||
const mode = useEditor((state) => state.mode);
|
||||
const tool = useEditor((state) => state.tool);
|
||||
const catalogCategory = useEditor((state) => state.catalogCategory);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const phase = useEditor((state) => state.phase)
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const tool = useEditor((state) => state.tool)
|
||||
const catalogCategory = useEditor((state) => state.catalogCategory)
|
||||
const reducedMotion = useReducedMotion()
|
||||
const transition = reducedMotion
|
||||
? { duration: 0 }
|
||||
: { type: "spring" as const, bounce: 0.2, duration: 0.4 };
|
||||
: { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<motion.div
|
||||
layout
|
||||
transition={transition}
|
||||
className={cn(
|
||||
"-translate-x-1/2 fixed bottom-6 left-1/2 z-50",
|
||||
"rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md",
|
||||
"transition-colors duration-200 ease-out",
|
||||
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2',
|
||||
'rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md',
|
||||
'transition-colors duration-200 ease-out',
|
||||
className,
|
||||
)}
|
||||
layout
|
||||
transition={transition}
|
||||
>
|
||||
{/* Item Catalog Row - Only show when in build mode with item tool */}
|
||||
<AnimatePresence>
|
||||
{mode === "build" && tool === "item" && catalogCategory && (
|
||||
{mode === 'build' && tool === 'item' && catalogCategory && (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"overflow-hidden border-border border-b px-2 py-2",
|
||||
)}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
maxHeight: 160,
|
||||
@@ -56,6 +45,7 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
paddingBottom: 8,
|
||||
borderBottomWidth: 1,
|
||||
}}
|
||||
className={cn('overflow-hidden border-border border-b px-2 py-2')}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
@@ -63,27 +53,23 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
transition={transition}
|
||||
>
|
||||
<ItemCatalog key={catalogCategory} category={catalogCategory} />
|
||||
<ItemCatalog category={catalogCategory} key={catalogCategory} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{phase === "furnish" && mode === "build" && (
|
||||
{phase === 'furnish' && mode === 'build' && (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"overflow-hidden border-border",
|
||||
"max-h-20 border-b px-2 py-2 opacity-100",
|
||||
)}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
maxHeight: 80,
|
||||
@@ -91,6 +77,10 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
paddingBottom: 8,
|
||||
borderBottomWidth: 1,
|
||||
}}
|
||||
className={cn(
|
||||
'overflow-hidden border-border',
|
||||
'max-h-20 border-b px-2 py-2 opacity-100',
|
||||
)}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
@@ -98,6 +88,13 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
transition={transition}
|
||||
>
|
||||
<div className="mx-auto w-max">
|
||||
@@ -109,18 +106,8 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
|
||||
{/* Structure Tools Row - Animated */}
|
||||
<AnimatePresence>
|
||||
{phase === "structure" && mode === "build" && (
|
||||
{phase === 'structure' && mode === 'build' && (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"overflow-hidden border-border max-h-20 border-b px-2 py-2",
|
||||
)}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
maxHeight: 80,
|
||||
@@ -128,6 +115,7 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
paddingBottom: 8,
|
||||
borderBottomWidth: 1,
|
||||
}}
|
||||
className={cn('max-h-20 overflow-hidden border-border border-b px-2 py-2')}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
@@ -135,6 +123,13 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
transition={transition}
|
||||
>
|
||||
<div className="w-max">
|
||||
@@ -153,5 +148,5 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
</div>
|
||||
</motion.div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import NextImage from 'next/image'
|
||||
import { ActionButton } from "./action-button";
|
||||
|
||||
import { cn } from '../../../lib/utils'
|
||||
import useEditor, { CatalogCategory, StructureTool, Tool } from '../../../store/use-editor'
|
||||
import { useContextualTools } from '../../../hooks/use-contextual-tools'
|
||||
|
||||
import { cn } from '../../../lib/utils'
|
||||
import useEditor, {
|
||||
type CatalogCategory,
|
||||
type StructureTool,
|
||||
Tool,
|
||||
} from '../../../store/use-editor'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
export type ToolConfig = {
|
||||
id: StructureTool; iconSrc: string; label: string; catalogCategory?: CatalogCategory }
|
||||
id: StructureTool
|
||||
iconSrc: string
|
||||
label: string
|
||||
catalogCategory?: CatalogCategory
|
||||
}
|
||||
|
||||
export const tools: ToolConfig[] = [
|
||||
{ id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' },
|
||||
@@ -26,19 +34,20 @@ export function StructureTools() {
|
||||
const activeTool = useEditor((state) => state.tool)
|
||||
const catalogCategory = useEditor((state) => state.catalogCategory)
|
||||
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 contextualTools = useContextualTools()
|
||||
|
||||
// Filter tools based on structureLayer
|
||||
const visibleTools = structureLayer === 'zones'
|
||||
? tools.filter((t) => t.id === 'zone')
|
||||
: tools.filter((t) => t.id !== 'zone')
|
||||
const visibleTools =
|
||||
structureLayer === 'zones'
|
||||
? tools.filter((t) => t.id === 'zone')
|
||||
: tools.filter((t) => t.id !== 'zone')
|
||||
|
||||
const hasActiveTool = visibleTools.some((t) =>
|
||||
activeTool === t.id &&
|
||||
(t.catalogCategory ? catalogCategory === t.catalogCategory : true)
|
||||
const hasActiveTool = visibleTools.some(
|
||||
(t) =>
|
||||
activeTool === t.id && (t.catalogCategory ? catalogCategory === t.catalogCategory : true),
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -48,22 +57,24 @@ export function StructureTools() {
|
||||
const isActive =
|
||||
activeTool === tool.id &&
|
||||
(tool.catalogCategory ? catalogCategory === tool.catalogCategory : true)
|
||||
|
||||
|
||||
const isContextual = contextualTools.includes(tool.id)
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
key={`${tool.id}-${tool.catalogCategory ?? index}`}
|
||||
label={tool.label}
|
||||
className={cn(
|
||||
'rounded-lg duration-300',
|
||||
isActive ? 'bg-black/40 hover:bg-black/40 scale-110 z-10' : 'bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95',
|
||||
isActive
|
||||
? 'z-10 scale-110 bg-black/40 hover:bg-black/40'
|
||||
: 'scale-95 bg-transparent opacity-60 grayscale hover:bg-black/20 hover:opacity-100 hover:grayscale-0',
|
||||
)}
|
||||
key={`${tool.id}-${tool.catalogCategory ?? index}`}
|
||||
label={tool.label}
|
||||
onClick={() => {
|
||||
if (!isActive) {
|
||||
setTool(tool.id)
|
||||
setCatalogCategory(tool.catalogCategory ?? null)
|
||||
|
||||
|
||||
// Automatically switch to build mode if we select a tool
|
||||
if (useEditor.getState().mode !== 'build') {
|
||||
useEditor.getState().setMode('build')
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Box, Camera, Diamond, Image, Layers, Layers2 } from 'lucide-react'
|
||||
import { ActionButton } from "./action-button";
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
||||
stacked: 'Stacked',
|
||||
@@ -77,12 +77,12 @@ export function ViewToggles() {
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Camera Mode */}
|
||||
<ActionButton
|
||||
label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}
|
||||
className={cn(
|
||||
cameraMode === 'orthographic'
|
||||
? 'bg-violet-500/20 text-violet-400'
|
||||
: 'hover:text-violet-400',
|
||||
)}
|
||||
label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}
|
||||
onClick={toggleCameraMode}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -92,12 +92,10 @@ export function ViewToggles() {
|
||||
|
||||
{/* Level Mode */}
|
||||
<ActionButton
|
||||
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
|
||||
className={cn(
|
||||
levelMode !== 'stacked'
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
: 'hover:text-amber-400',
|
||||
levelMode !== 'stacked' ? 'bg-amber-500/20 text-amber-400' : 'hover:text-amber-400',
|
||||
)}
|
||||
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
|
||||
onClick={cycleLevelMode}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -109,13 +107,13 @@ export function ViewToggles() {
|
||||
|
||||
{/* Wall Mode */}
|
||||
<ActionButton
|
||||
label={`Walls: ${wallModeConfig[wallMode].label}`}
|
||||
className={cn(
|
||||
'p-0',
|
||||
wallMode !== 'cutaway'
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
||||
)}
|
||||
label={`Walls: ${wallModeConfig[wallMode].label}`}
|
||||
onClick={cycleWallMode}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -128,13 +126,13 @@ export function ViewToggles() {
|
||||
|
||||
{/* Show Scans */}
|
||||
<ActionButton
|
||||
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
|
||||
className={cn(
|
||||
'p-0',
|
||||
showScans
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
||||
)}
|
||||
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
|
||||
onClick={() => setShowScans(!showScans)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -144,13 +142,13 @@ export function ViewToggles() {
|
||||
|
||||
{/* Show Guides */}
|
||||
<ActionButton
|
||||
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
|
||||
className={cn(
|
||||
'p-0',
|
||||
showGuides
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
||||
)}
|
||||
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
|
||||
onClick={() => setShowGuides(!showGuides)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,8 +12,8 @@ export function ActionButton({ icon, label, className, ...props }: ActionButtonP
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
"flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-xs font-medium text-foreground transition-colors hover:bg-[#3e3e3e] active:bg-[#3e3e3e]",
|
||||
className
|
||||
'flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 font-medium text-foreground text-xs transition-colors hover:bg-[#3e3e3e] active:bg-[#3e3e3e]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
@@ -22,10 +22,12 @@ export function ActionButton({ icon, label, className, ...props }: ActionButtonP
|
||||
)
|
||||
}
|
||||
|
||||
export function ActionGroup({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex gap-1.5", className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
export function ActionGroup({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return <div className={cn('flex gap-1.5', className)}>{children}</div>
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ export function MetricControl({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min = -Infinity,
|
||||
max = Infinity,
|
||||
min = Number.NEGATIVE_INFINITY,
|
||||
max = Number.POSITIVE_INFINITY,
|
||||
precision = 2,
|
||||
step = 1,
|
||||
className,
|
||||
@@ -57,9 +57,9 @@ export function MetricControl({
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (isEditing) return
|
||||
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
|
||||
const direction = e.deltaY < 0 ? 1 : -1
|
||||
let scrollStep = step
|
||||
if (e.shiftKey) scrollStep = step * 10
|
||||
@@ -67,12 +67,12 @@ export function MetricControl({
|
||||
|
||||
const newValue = clamp(valueRef.current + direction * scrollStep)
|
||||
const finalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||
|
||||
|
||||
if (finalValue !== valueRef.current) {
|
||||
onChange(finalValue)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => container.removeEventListener('wheel', handleWheel)
|
||||
}, [isEditing, step, clamp, onChange, precision])
|
||||
@@ -84,16 +84,16 @@ export function MetricControl({
|
||||
let direction = 0
|
||||
if (e.key === 'ArrowUp') direction = 1
|
||||
else if (e.key === 'ArrowDown') direction = -1
|
||||
|
||||
|
||||
if (direction !== 0) {
|
||||
e.preventDefault()
|
||||
let scrollStep = step
|
||||
if (e.shiftKey) scrollStep = step * 10
|
||||
else if (e.altKey) scrollStep = step * 0.1
|
||||
|
||||
|
||||
const newValue = clamp(valueRef.current + direction * scrollStep)
|
||||
const finalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||
|
||||
|
||||
if (finalValue !== valueRef.current) {
|
||||
onChange(finalValue)
|
||||
}
|
||||
@@ -108,17 +108,17 @@ export function MetricControl({
|
||||
(e: React.PointerEvent) => {
|
||||
if (isEditing) return
|
||||
e.preventDefault()
|
||||
|
||||
|
||||
setIsDragging(true)
|
||||
startXRef.current = e.clientX
|
||||
startValueRef.current = value
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
|
||||
let finalValue = value
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const deltaX = moveEvent.clientX - startXRef.current
|
||||
|
||||
|
||||
let dragStep = step
|
||||
if (moveEvent.shiftKey) dragStep = step * 10
|
||||
else if (moveEvent.altKey) dragStep = step * 0.1
|
||||
@@ -137,7 +137,7 @@ export function MetricControl({
|
||||
setIsDragging(false)
|
||||
document.removeEventListener('pointermove', handlePointerMove)
|
||||
document.removeEventListener('pointerup', handlePointerUp)
|
||||
|
||||
|
||||
if (finalValue !== startValueRef.current) {
|
||||
onChange(startValueRef.current)
|
||||
useScene.temporal.getState().resume()
|
||||
@@ -150,7 +150,7 @@ export function MetricControl({
|
||||
document.addEventListener('pointermove', handlePointerMove)
|
||||
document.addEventListener('pointerup', handlePointerUp)
|
||||
},
|
||||
[isEditing, value, onChange, clamp, precision, step]
|
||||
[isEditing, value, onChange, clamp, precision, step],
|
||||
)
|
||||
|
||||
const handleValueClick = useCallback(() => {
|
||||
@@ -164,10 +164,10 @@ export function MetricControl({
|
||||
|
||||
const submitValue = useCallback(() => {
|
||||
const numValue = Number.parseFloat(inputValue)
|
||||
if (!Number.isNaN(numValue)) {
|
||||
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
|
||||
} else {
|
||||
if (Number.isNaN(numValue)) {
|
||||
setInputValue(value.toFixed(precision))
|
||||
} else {
|
||||
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
|
||||
}
|
||||
setIsEditing(false)
|
||||
}, [inputValue, onChange, clamp, precision, value])
|
||||
@@ -199,28 +199,34 @@ export function MetricControl({
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
<div
|
||||
className={cn(
|
||||
'group flex h-10 w-full items-center justify-between rounded-lg border border-border/50 px-3 text-sm transition-colors',
|
||||
isDragging ? 'bg-[#3e3e3e]' : 'bg-[#2C2C2E] hover:bg-[#3e3e3e]',
|
||||
className,
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={cn("group flex h-10 w-full items-center justify-between rounded-lg border border-border/50 px-3 text-sm transition-colors", isDragging ? "bg-[#3e3e3e]" : "bg-[#2C2C2E] hover:bg-[#3e3e3e]", className)}
|
||||
ref={containerRef}
|
||||
>
|
||||
<div
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground select-none truncate transition-colors",
|
||||
isDragging ? "cursor-ew-resize text-foreground" : "hover:text-foreground hover:cursor-ew-resize"
|
||||
'select-none truncate text-muted-foreground transition-colors',
|
||||
isDragging
|
||||
? 'cursor-ew-resize text-foreground'
|
||||
: 'hover:cursor-ew-resize hover:text-foreground',
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex shrink-0 justify-end">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full bg-transparent p-0 text-right text-foreground font-mono outline-none selection:bg-primary/30"
|
||||
className="w-full bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
|
||||
onBlur={handleInputBlur}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
@@ -231,7 +237,7 @@ export function MetricControl({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex w-full cursor-text items-center justify-end text-foreground hover:text-primary transition-colors"
|
||||
className="flex w-full cursor-text items-center justify-end text-foreground transition-colors hover:text-primary"
|
||||
onClick={handleValueClick}
|
||||
>
|
||||
<span className="font-mono tabular-nums tracking-tight">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { cn } from '../../../lib/utils'
|
||||
|
||||
interface PanelSectionProps {
|
||||
title: string
|
||||
@@ -21,44 +21,42 @@ export function PanelSection({
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<motion.div
|
||||
className={cn('flex shrink-0 flex-col overflow-hidden border-border/50 border-b', className)}
|
||||
layout
|
||||
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
|
||||
className={cn("flex flex-col shrink-0 overflow-hidden border-b border-border/50", className)}
|
||||
transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
|
||||
>
|
||||
<motion.button
|
||||
layout="position"
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={cn(
|
||||
"group/section flex items-center justify-between h-10 px-3 transition-all duration-200 shrink-0",
|
||||
'group/section flex h-10 shrink-0 items-center justify-between px-3 transition-all duration-200',
|
||||
isExpanded
|
||||
? "bg-accent/50 text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
|
||||
? 'bg-accent/50 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
|
||||
)}
|
||||
layout="position"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
type="button"
|
||||
>
|
||||
<span className="font-medium text-sm truncate">{title}</span>
|
||||
<ChevronDown
|
||||
<span className="truncate font-medium text-sm">{title}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
isExpanded ? "rotate-180" : "rotate-0",
|
||||
isExpanded ? "text-foreground" : "opacity-0 group-hover/section:opacity-100"
|
||||
)}
|
||||
'h-4 w-4 transition-transform duration-200',
|
||||
isExpanded ? 'rotate-180' : 'rotate-0',
|
||||
isExpanded ? 'text-foreground' : 'opacity-0 group-hover/section:opacity-100',
|
||||
)}
|
||||
/>
|
||||
</motion.button>
|
||||
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
className="overflow-hidden"
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 p-3 pt-2">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 p-3 pt-2">{children}</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -16,20 +16,25 @@ export function SegmentedControl<T extends string>({
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div className={cn("flex h-9 w-full items-center rounded-lg border border-border/50 bg-[#2C2C2E] p-[3px]", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center rounded-lg border border-border/50 bg-[#2C2C2E] p-[3px]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isSelected = value === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"relative flex h-full flex-1 items-center justify-center rounded-md text-xs font-medium transition-all duration-200",
|
||||
'relative flex h-full flex-1 items-center justify-center rounded-md font-medium text-xs transition-all duration-200',
|
||||
isSelected
|
||||
? "bg-[#3e3e3e] text-foreground shadow-sm ring-1 ring-border/50"
|
||||
: "text-muted-foreground hover:bg-white/5 hover:text-foreground"
|
||||
? 'bg-[#3e3e3e] text-foreground shadow-sm ring-1 ring-border/50'
|
||||
: 'text-muted-foreground hover:bg-white/5 hover:text-foreground',
|
||||
)}
|
||||
key={option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
type="button"
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-1.5">{option.label}</span>
|
||||
</button>
|
||||
|
||||
@@ -31,12 +31,12 @@ export function SliderControl({
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(value.toFixed(precision))
|
||||
|
||||
|
||||
// Track the original value and bounds when dragging starts
|
||||
const [dragStartValue, setDragStartValue] = useState<number | null>(null)
|
||||
const [dragMin, setDragMin] = useState<number | null>(null)
|
||||
const [dragMax, setDragMax] = useState<number | null>(null)
|
||||
|
||||
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -62,9 +62,9 @@ export function SliderControl({
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (isEditing) return
|
||||
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
|
||||
const direction = e.deltaY < 0 ? 1 : -1
|
||||
let scrollStep = step
|
||||
if (e.shiftKey) scrollStep = step * 10
|
||||
@@ -72,12 +72,12 @@ export function SliderControl({
|
||||
|
||||
const newValue = clamp(valueRef.current + direction * scrollStep)
|
||||
const finalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||
|
||||
|
||||
if (finalValue !== valueRef.current) {
|
||||
onChange(finalValue)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => container.removeEventListener('wheel', handleWheel)
|
||||
}, [isEditing, step, clamp, onChange, precision])
|
||||
@@ -89,16 +89,16 @@ export function SliderControl({
|
||||
let direction = 0
|
||||
if (e.key === 'ArrowUp') direction = 1
|
||||
else if (e.key === 'ArrowDown') direction = -1
|
||||
|
||||
|
||||
if (direction !== 0) {
|
||||
e.preventDefault()
|
||||
let scrollStep = step
|
||||
if (e.shiftKey) scrollStep = step * 10
|
||||
else if (e.altKey) scrollStep = step * 0.1
|
||||
|
||||
|
||||
const newValue = clamp(valueRef.current + direction * scrollStep)
|
||||
const finalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||
|
||||
|
||||
if (finalValue !== valueRef.current) {
|
||||
onChange(finalValue)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export function SliderControl({
|
||||
(e: React.PointerEvent) => {
|
||||
if (isEditing) return
|
||||
e.preventDefault()
|
||||
|
||||
|
||||
const track = trackRef.current
|
||||
if (!track) return
|
||||
|
||||
@@ -122,7 +122,7 @@ export function SliderControl({
|
||||
setDragMin(min)
|
||||
setDragMax(max)
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
|
||||
const rect = track.getBoundingClientRect()
|
||||
const updateValueFromEvent = (clientX: number) => {
|
||||
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||
@@ -145,7 +145,7 @@ export function SliderControl({
|
||||
if ((e.target as HTMLElement).closest('button')) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
setIsDragging(false)
|
||||
const startVal = dragStartValue
|
||||
const finalVal = valueRef.current
|
||||
@@ -159,9 +159,9 @@ export function SliderControl({
|
||||
if (startVal !== null && startVal !== finalVal) {
|
||||
// Revert to start value while paused so the undo baseline is clean
|
||||
onChange(startVal)
|
||||
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
|
||||
// Apply final value while recording
|
||||
onChange(finalVal)
|
||||
} else {
|
||||
@@ -172,7 +172,7 @@ export function SliderControl({
|
||||
document.addEventListener('pointermove', handlePointerMove)
|
||||
document.addEventListener('pointerup', handlePointerUp)
|
||||
},
|
||||
[isEditing, min, max, step, precision, clamp, onChange]
|
||||
[isEditing, min, max, step, precision, clamp, onChange],
|
||||
)
|
||||
|
||||
const handleValueClick = useCallback(() => {
|
||||
@@ -186,10 +186,10 @@ export function SliderControl({
|
||||
|
||||
const submitValue = useCallback(() => {
|
||||
const numValue = Number.parseFloat(inputValue)
|
||||
if (!Number.isNaN(numValue)) {
|
||||
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
|
||||
} else {
|
||||
if (Number.isNaN(numValue)) {
|
||||
setInputValue(value.toFixed(precision))
|
||||
} else {
|
||||
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
|
||||
}
|
||||
setIsEditing(false)
|
||||
}, [inputValue, onChange, clamp, precision, value])
|
||||
@@ -223,20 +223,33 @@ export function SliderControl({
|
||||
const currentMin = isDragging && dragMin !== null ? dragMin : min
|
||||
const currentMax = isDragging && dragMax !== null ? dragMax : max
|
||||
|
||||
const percent = Math.max(0, Math.min(100, ((value - currentMin) / (currentMax - currentMin)) * 100))
|
||||
const startPercent = dragStartValue !== null ? Math.max(0, Math.min(100, ((dragStartValue - currentMin) / (currentMax - currentMin)) * 100)) : null
|
||||
const percent = Math.max(
|
||||
0,
|
||||
Math.min(100, ((value - currentMin) / (currentMax - currentMin)) * 100),
|
||||
)
|
||||
const startPercent =
|
||||
dragStartValue !== null
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(100, ((dragStartValue - currentMin) / (currentMax - currentMin)) * 100),
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex h-12 w-full items-center rounded-lg border border-border/50 px-3 text-sm transition-colors',
|
||||
isDragging ? 'bg-[#3e3e3e]' : 'bg-[#2C2C2E] hover:bg-[#3e3e3e]',
|
||||
className,
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={cn("group flex h-12 w-full items-center rounded-lg border border-border/50 px-3 text-sm transition-colors relative", isDragging ? "bg-[#3e3e3e]" : "bg-[#2C2C2E] hover:bg-[#3e3e3e]", className)}
|
||||
ref={containerRef}
|
||||
>
|
||||
{/* Reset button that appears when dragged away from start */}
|
||||
{isDragging && dragStartValue !== null && dragStartValue !== value && (
|
||||
<button
|
||||
className="absolute -top-10 right-0 rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] font-medium text-muted-foreground shadow-sm ring-1 ring-border/50 hover:bg-[#3e3e3e] hover:text-foreground z-50 pointer-events-auto cursor-pointer"
|
||||
className="pointer-events-auto absolute -top-10 right-0 z-50 cursor-pointer rounded-md bg-[#2C2C2E] px-2 py-1 font-medium text-[10px] text-muted-foreground shadow-sm ring-1 ring-border/50 hover:bg-[#3e3e3e] hover:text-foreground"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange(dragStartValue)
|
||||
@@ -251,38 +264,38 @@ export function SliderControl({
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="w-[80px] shrink-0 text-muted-foreground select-none truncate">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={trackRef}
|
||||
<div className="w-[80px] shrink-0 select-none truncate text-muted-foreground">{label}</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-full flex-1 items-center justify-center touch-none mx-2",
|
||||
isDragging ? "cursor-grabbing" : "cursor-grab"
|
||||
'relative mx-2 flex h-full flex-1 touch-none items-center justify-center',
|
||||
isDragging ? 'cursor-grabbing' : 'cursor-grab',
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
ref={trackRef}
|
||||
>
|
||||
{/* Track dots background */}
|
||||
<div className="absolute inset-x-0 flex items-center justify-between opacity-30 px-1 pointer-events-none">
|
||||
<div className="pointer-events-none absolute inset-x-0 flex items-center justify-between px-1 opacity-30">
|
||||
{[...Array(9)].map((_, i) => (
|
||||
<div key={i} className="h-[3px] w-[3px] rounded-full bg-current" />
|
||||
<div className="h-[3px] w-[3px] rounded-full bg-current" key={i} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Original Thumb Ghost */}
|
||||
{isDragging && startPercent !== null && (
|
||||
<div
|
||||
className="absolute top-1/2 h-6 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm bg-foreground/20 pointer-events-none"
|
||||
<div
|
||||
className="pointer-events-none absolute top-1/2 h-6 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground/20 shadow-sm"
|
||||
style={{ left: `${startPercent}%` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* Active Thumb */}
|
||||
<div
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-1/2 h-6 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm transition pointer-events-none",
|
||||
isDragging ? "bg-foreground scale-y-110" : "bg-foreground/60 group-hover:bg-foreground/80"
|
||||
'pointer-events-none absolute top-1/2 h-6 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm transition',
|
||||
isDragging
|
||||
? 'scale-y-110 bg-foreground'
|
||||
: 'bg-foreground/60 group-hover:bg-foreground/80',
|
||||
)}
|
||||
style={{ left: `${percent}%` }}
|
||||
/>
|
||||
@@ -293,7 +306,7 @@ export function SliderControl({
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full bg-transparent p-0 text-right text-foreground font-mono outline-none selection:bg-primary/30"
|
||||
className="w-full bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
|
||||
onBlur={handleInputBlur}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
@@ -304,7 +317,7 @@ export function SliderControl({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex w-full cursor-text items-center justify-end text-foreground/60 hover:text-foreground transition-colors"
|
||||
className="flex w-full cursor-text items-center justify-end text-foreground/60 transition-colors hover:text-foreground"
|
||||
onClick={handleValueClick}
|
||||
>
|
||||
<span className="font-mono tabular-nums tracking-tight">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '../../../lib/utils'
|
||||
|
||||
interface ToggleControlProps {
|
||||
label: string
|
||||
@@ -10,27 +10,25 @@ interface ToggleControlProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ToggleControl({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
className,
|
||||
}: ToggleControlProps) {
|
||||
export function ToggleControl({ label, checked, onChange, className }: ToggleControlProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("group flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm transition-colors hover:bg-[#3e3e3e]", className)}
|
||||
<div
|
||||
className={cn(
|
||||
'group flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm transition-colors hover:bg-[#3e3e3e]',
|
||||
className,
|
||||
)}
|
||||
onClick={() => onChange(!checked)}
|
||||
>
|
||||
<div className="text-muted-foreground transition-colors group-hover:text-foreground select-none">
|
||||
<div className="select-none text-muted-foreground transition-colors group-hover:text-foreground">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 w-5 items-center justify-center rounded-[4px] border transition-all duration-200",
|
||||
checked
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-black/20 text-transparent group-hover:border-muted-foreground"
|
||||
'flex h-5 w-5 items-center justify-center rounded-[4px] border transition-all duration-200',
|
||||
checked
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border bg-black/20 text-transparent group-hover:border-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" strokeWidth={3} />
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export function CeilingHelper() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Shift</kbd>
|
||||
<span className="text-muted-foreground">Allow non-45° angles</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Esc</kbd>
|
||||
<span className="text-muted-foreground">Cancel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,22 +4,22 @@ interface ItemHelperProps {
|
||||
|
||||
export function ItemHelper({ showEsc }: ItemHelperProps) {
|
||||
return (
|
||||
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">R</kbd>
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">R</kbd>
|
||||
<span className="text-muted-foreground">Rotate counterclockwise</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">T</kbd>
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">T</kbd>
|
||||
<span className="text-muted-foreground">Rotate clockwise</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Shift</kbd>
|
||||
<span className="text-muted-foreground">Free place</span>
|
||||
</div>
|
||||
{showEsc && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Esc</kbd>
|
||||
<span className="text-muted-foreground">Cancel</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export function RoofHelper() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Esc</kbd>
|
||||
<span className="text-muted-foreground">Cancel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export function SlabHelper() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Shift</kbd>
|
||||
<span className="text-muted-foreground">Allow non-45° angles</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Esc</kbd>
|
||||
<span className="text-muted-foreground">Cancel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export function WallHelper() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Shift</kbd>
|
||||
<span className="text-muted-foreground">Allow non-45° angles</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
|
||||
<kbd className="rounded bg-muted px-2 py-1 font-medium text-xs">Esc</kbd>
|
||||
<span className="text-muted-foreground">Cancel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,83 +1,77 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { AssetInput } from "@pascal-app/core";
|
||||
import { resolveCdnUrl } from "@pascal-app/viewer";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AssetInput } from '@pascal-app/core'
|
||||
import { resolveCdnUrl } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "./../../../components/ui/primitives/tooltip";
|
||||
import { cn } from "./../../../lib/utils";
|
||||
import useEditor, { CatalogCategory } from "./../../../store/use-editor";
|
||||
import { CATALOG_ITEMS } from "./catalog-items";
|
||||
} from './../../../components/ui/primitives/tooltip'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
|
||||
import { CATALOG_ITEMS } from './catalog-items'
|
||||
|
||||
const PLACEMENT_TAGS = new Set(["floor", "wall", "ceiling", "countertop"]);
|
||||
const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop'])
|
||||
|
||||
export function ItemCatalog({ category }: { category: CatalogCategory }) {
|
||||
const selectedItem = useEditor((state) => state.selectedItem);
|
||||
const setSelectedItem = useEditor((state) => state.setSelectedItem);
|
||||
const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null);
|
||||
const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null);
|
||||
const selectedItem = useEditor((state) => state.selectedItem)
|
||||
const setSelectedItem = useEditor((state) => state.setSelectedItem)
|
||||
const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null)
|
||||
const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null)
|
||||
|
||||
const categoryItems = CATALOG_ITEMS.filter(
|
||||
(item) => item.category === category,
|
||||
);
|
||||
const categoryItems = CATALOG_ITEMS.filter((item) => item.category === category)
|
||||
|
||||
// Collect tags available in this category
|
||||
const allTags = Array.from(
|
||||
new Set(categoryItems.flatMap((item) => item.tags ?? [])),
|
||||
);
|
||||
const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t));
|
||||
const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t));
|
||||
const hasFilters = allTags.length > 1;
|
||||
const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? [])))
|
||||
const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t))
|
||||
const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t))
|
||||
const hasFilters = allTags.length > 1
|
||||
|
||||
// Count items for a placement tag given the current functional filter
|
||||
const placementCount = (tag: string | null) =>
|
||||
categoryItems.filter((item) => {
|
||||
const tags = item.tags ?? [];
|
||||
if (tag !== null && !tags.includes(tag)) return false;
|
||||
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false;
|
||||
return true;
|
||||
}).length;
|
||||
const tags = item.tags ?? []
|
||||
if (tag !== null && !tags.includes(tag)) return false
|
||||
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
|
||||
return true
|
||||
}).length
|
||||
|
||||
// Count items for a functional tag given the current placement filter
|
||||
const functionalCount = (tag: string) =>
|
||||
categoryItems.filter((item) => {
|
||||
const tags = item.tags ?? [];
|
||||
if (!tags.includes(tag)) return false;
|
||||
if (activePlacementTag && !tags.includes(activePlacementTag)) return false;
|
||||
return true;
|
||||
}).length;
|
||||
const tags = item.tags ?? []
|
||||
if (!tags.includes(tag)) return false
|
||||
if (activePlacementTag && !tags.includes(activePlacementTag)) return false
|
||||
return true
|
||||
}).length
|
||||
|
||||
const filteredItems = categoryItems.filter((item) => {
|
||||
const tags = item.tags ?? [];
|
||||
if (activePlacementTag && !tags.includes(activePlacementTag)) return false;
|
||||
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false;
|
||||
return true;
|
||||
});
|
||||
const tags = item.tags ?? []
|
||||
if (activePlacementTag && !tags.includes(activePlacementTag)) return false
|
||||
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// Auto-select first item if current selection is not in the filtered list
|
||||
useEffect(() => {
|
||||
const isCurrentItemInCategory = filteredItems.some(
|
||||
(item) => item.src === selectedItem?.src,
|
||||
);
|
||||
const isCurrentItemInCategory = filteredItems.some((item) => item.src === selectedItem?.src)
|
||||
if (!isCurrentItemInCategory && filteredItems.length > 0) {
|
||||
setSelectedItem(filteredItems[0] as AssetInput);
|
||||
setSelectedItem(filteredItems[0] as AssetInput)
|
||||
}
|
||||
}, [filteredItems, selectedItem?.src, setSelectedItem]);
|
||||
}, [filteredItems, selectedItem?.src, setSelectedItem])
|
||||
|
||||
// Get attachment icon based on attachTo type
|
||||
const getAttachmentIcon = (attachTo: AssetInput["attachTo"]) => {
|
||||
if (attachTo === "wall" || attachTo === "wall-side") {
|
||||
return "/icons/wall.png";
|
||||
const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => {
|
||||
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
||||
return '/icons/wall.png'
|
||||
}
|
||||
if (attachTo === "ceiling") {
|
||||
return "/icons/ceiling.png";
|
||||
if (attachTo === 'ceiling') {
|
||||
return '/icons/ceiling.png'
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -88,42 +82,47 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
|
||||
{placementTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActivePlacementTag(null)}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-md px-2 py-0.5 text-xs font-medium transition-colors",
|
||||
'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',
|
||||
activePlacementTag === null
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200",
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
|
||||
)}
|
||||
onClick={() => setActivePlacementTag(null)}
|
||||
type="button"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{placementTags.map((tag) => {
|
||||
const count = placementCount(tag);
|
||||
const isActive = activePlacementTag === tag;
|
||||
const isEmpty = count === 0 && !isActive;
|
||||
const count = placementCount(tag)
|
||||
const isActive = activePlacementTag === tag
|
||||
const isEmpty = count === 0 && !isActive
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
disabled={isEmpty}
|
||||
onClick={() => setActivePlacementTag(isActive ? null : tag)}
|
||||
className={cn(
|
||||
"inline-flex cursor-pointer items-center gap-1 rounded-md pl-2 pr-1.5 py-0.5 text-xs font-medium transition-colors capitalize",
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',
|
||||
isActive
|
||||
? "bg-blue-500 text-white"
|
||||
? 'bg-blue-500 text-white'
|
||||
: isEmpty
|
||||
? "cursor-not-allowed bg-zinc-800 text-zinc-500"
|
||||
: "bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200",
|
||||
? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
|
||||
: 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
|
||||
)}
|
||||
disabled={isEmpty}
|
||||
key={tag}
|
||||
onClick={() => setActivePlacementTag(isActive ? null : tag)}
|
||||
type="button"
|
||||
>
|
||||
{tag}
|
||||
<span className={cn("text-[10px]", isActive ? "text-blue-200" : isEmpty ? "text-zinc-600" : "text-blue-500/70")}>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px]',
|
||||
isActive ? 'text-blue-200' : isEmpty ? 'text-zinc-600' : 'text-blue-500/70',
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
@@ -132,30 +131,39 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
|
||||
{functionalTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{functionalTags.map((tag) => {
|
||||
const count = functionalCount(tag);
|
||||
const isActive = activeFunctionalTag === tag;
|
||||
const isEmpty = count === 0 && !isActive;
|
||||
const count = functionalCount(tag)
|
||||
const isActive = activeFunctionalTag === tag
|
||||
const isEmpty = count === 0 && !isActive
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
disabled={isEmpty}
|
||||
onClick={() => setActiveFunctionalTag(isActive ? null : tag)}
|
||||
className={cn(
|
||||
"inline-flex cursor-pointer items-center gap-1 rounded-md pl-2 pr-1.5 py-0.5 text-xs font-medium transition-colors capitalize",
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',
|
||||
isActive
|
||||
? "bg-violet-500 text-white"
|
||||
? 'bg-violet-500 text-white'
|
||||
: isEmpty
|
||||
? "cursor-not-allowed bg-zinc-800 text-zinc-500"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground",
|
||||
? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
|
||||
)}
|
||||
disabled={isEmpty}
|
||||
key={tag}
|
||||
onClick={() => setActiveFunctionalTag(isActive ? null : tag)}
|
||||
type="button"
|
||||
>
|
||||
{tag}
|
||||
<span className={cn("text-[10px]", isActive ? "text-violet-200" : isEmpty ? "text-zinc-600" : "text-zinc-500/70")}>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px]',
|
||||
isActive
|
||||
? 'text-violet-200'
|
||||
: isEmpty
|
||||
? 'text-zinc-600'
|
||||
: 'text-zinc-500/70',
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
@@ -165,15 +173,15 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
|
||||
{/* Items */}
|
||||
<div className="-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2">
|
||||
{filteredItems.map((item, index) => {
|
||||
const isSelected = selectedItem?.src === item?.src;
|
||||
const attachmentIcon = getAttachmentIcon(item?.attachTo);
|
||||
const isSelected = selectedItem?.src === item?.src
|
||||
const attachmentIcon = getAttachmentIcon(item?.attachTo)
|
||||
return (
|
||||
<Tooltip key={index}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"relative aspect-square min-w-14 min-h-14 h-14 w-14 shrink-0 flex-col gap-px rounded-lg transition-all duration-200 ease-out hover:scale-105 hover:cursor-pointer",
|
||||
isSelected && "ring-2 ring-primary-foreground",
|
||||
'relative aspect-square h-14 min-h-14 w-14 min-w-14 shrink-0 flex-col gap-px rounded-lg transition-all duration-200 ease-out hover:scale-105 hover:cursor-pointer',
|
||||
isSelected && 'ring-2 ring-primary-foreground',
|
||||
)}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
type="button"
|
||||
@@ -182,16 +190,12 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
|
||||
alt={item.name}
|
||||
className="rounded-lg object-cover"
|
||||
fill
|
||||
src={resolveCdnUrl(item.thumbnail) || ""}
|
||||
src={resolveCdnUrl(item.thumbnail) || ''}
|
||||
/>
|
||||
{attachmentIcon && (
|
||||
<div className="absolute right-0.5 bottom-0.5 flex h-4 w-4 items-center justify-center rounded bg-black/60">
|
||||
<Image
|
||||
alt={
|
||||
item.attachTo === "ceiling"
|
||||
? "Ceiling attachment"
|
||||
: "Wall attachment"
|
||||
}
|
||||
alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
|
||||
className="h-4 w-4"
|
||||
height={16}
|
||||
src={attachmentIcon}
|
||||
@@ -205,9 +209,9 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
|
||||
{item.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { Edit, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { ActionButton } from '../controls/action-button'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { ActionButton } from '../controls/action-button'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
export function CeilingPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
@@ -50,7 +49,7 @@ export function CeilingPanel() {
|
||||
}, [setEditingHole])
|
||||
|
||||
const handleAddHole = useCallback(() => {
|
||||
if (!node || !selectedId) return
|
||||
if (!(node && selectedId)) return
|
||||
|
||||
const polygon = node.polygon
|
||||
let cx = 0
|
||||
@@ -113,23 +112,23 @@ export function CeilingPanel() {
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
title={node.name || "Ceiling"}
|
||||
icon="/icons/ceiling.png"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Ceiling'}
|
||||
width={320}
|
||||
>
|
||||
<PanelSection title="Height">
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(node.height * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ height: v })}
|
||||
min={0}
|
||||
max={6}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ height: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.height * 1000) / 1000}
|
||||
/>
|
||||
|
||||
|
||||
<div className="mt-2 grid grid-cols-3 gap-1.5 px-1 pb-1">
|
||||
<ActionButton label="Low (2.4m)" onClick={() => handleUpdate({ height: 2.4 })} />
|
||||
<ActionButton label="Standard (2.5m)" onClick={() => handleUpdate({ height: 2.5 })} />
|
||||
@@ -138,7 +137,7 @@ export function CeilingPanel() {
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Info">
|
||||
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between px-2 py-1 text-muted-foreground text-sm">
|
||||
<span>Area</span>
|
||||
<span className="font-mono text-white">{area.toFixed(2)} m²</span>
|
||||
</div>
|
||||
@@ -149,18 +148,21 @@ export function CeilingPanel() {
|
||||
<div className="flex flex-col gap-1 pb-2">
|
||||
{node.holes.map((hole, index) => {
|
||||
const holeArea = calculateArea(hole)
|
||||
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||
const isEditing =
|
||||
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
||||
isEditing
|
||||
? 'border-primary/50 bg-primary/10'
|
||||
: 'border-transparent hover:bg-accent/30'
|
||||
}`}
|
||||
key={index}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-xs font-medium ${isEditing ? 'text-primary' : 'text-white'}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`font-medium text-xs ${isEditing ? 'text-primary' : 'text-white'}`}
|
||||
>
|
||||
Hole {index + 1} {isEditing && '(Editing)'}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
@@ -169,24 +171,24 @@ export function CeilingPanel() {
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isEditing ? (
|
||||
<ActionButton
|
||||
label="Done"
|
||||
onClick={() => setEditingHole(null)}
|
||||
<ActionButton
|
||||
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
label="Done"
|
||||
onClick={() => setEditingHole(null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground"
|
||||
onClick={() => handleEditHole(index)}
|
||||
type="button"
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300"
|
||||
onClick={() => handleDeleteHole(index)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -198,18 +200,16 @@ export function CeilingPanel() {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||
No holes
|
||||
</div>
|
||||
<div className="px-2 py-3 text-center text-muted-foreground text-xs">No holes</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="px-1 pt-1 pb-1">
|
||||
<ActionButton
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
label="Add Hole"
|
||||
onClick={handleAddHole}
|
||||
<ActionButton
|
||||
className="w-full"
|
||||
disabled={editingHole?.nodeId === selectedId}
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
label="Add Hole"
|
||||
onClick={handleAddHole}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
@@ -2,11 +2,30 @@
|
||||
|
||||
import type { AnyNodeId, Collection, CollectionId } from '@pascal-app/core'
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Layers,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../../../components/ui/primitives/dropdown-menu'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../../../../components/ui/primitives/popover'
|
||||
import { ColorDot } from '../../../../components/ui/primitives/color-dot'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../../../components/ui/primitives/dropdown-menu'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '../../../../components/ui/primitives/popover'
|
||||
import { cn } from '../../../../lib/utils'
|
||||
|
||||
interface CollectionsPopoverProps {
|
||||
@@ -69,24 +88,29 @@ export function CollectionsPopover({ nodeId, collectionIds, children }: Collecti
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="left"
|
||||
align="start"
|
||||
className="w-72 overflow-hidden rounded-xl border-border/50 bg-sidebar/95 p-0 shadow-2xl backdrop-blur-xl"
|
||||
side="left"
|
||||
sideOffset={8}
|
||||
className="w-72 p-0 border-border/50 bg-sidebar/95 backdrop-blur-xl shadow-2xl rounded-xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border/50">
|
||||
<div className="flex items-center justify-between border-border/50 border-b px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold text-foreground tracking-tight">Collections</span>
|
||||
<span className="font-semibold text-foreground text-xs tracking-tight">
|
||||
Collections
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
|
||||
onClick={() => {
|
||||
setShowCreateInput((v) => !v)
|
||||
setCreateName('')
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => { setShowCreateInput((v) => !v); setCreateName('') }}
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
New
|
||||
@@ -95,30 +119,36 @@ export function CollectionsPopover({ nodeId, collectionIds, children }: Collecti
|
||||
|
||||
{/* Create input */}
|
||||
{showCreateInput && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50 bg-white/5">
|
||||
<div className="flex items-center gap-1.5 border-border/50 border-b bg-white/5 px-3 py-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={createName}
|
||||
className="min-w-0 flex-1 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-foreground text-xs outline-none placeholder:text-muted-foreground/60 focus:border-ring focus:ring-1 focus:ring-ring/30"
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreate()
|
||||
if (e.key === 'Escape') { setShowCreateInput(false); setCreateName('') }
|
||||
if (e.key === 'Escape') {
|
||||
setShowCreateInput(false)
|
||||
setCreateName('')
|
||||
}
|
||||
}}
|
||||
placeholder="Collection name…"
|
||||
className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground/60 outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
|
||||
value={createName}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 text-primary transition-colors hover:bg-primary/30 disabled:opacity-40"
|
||||
disabled={!createName.trim()}
|
||||
onClick={handleCreate}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary disabled:opacity-40 transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-white/10"
|
||||
onClick={() => {
|
||||
setShowCreateInput(false)
|
||||
setCreateName('')
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => { setShowCreateInput(false); setCreateName('') }}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -126,11 +156,11 @@ export function CollectionsPopover({ nodeId, collectionIds, children }: Collecti
|
||||
)}
|
||||
|
||||
{/* Collections list */}
|
||||
<div className="max-h-72 overflow-y-auto no-scrollbar">
|
||||
<div className="no-scrollbar max-h-72 overflow-y-auto">
|
||||
{allCollections.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center px-4">
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center">
|
||||
<Layers className="h-6 w-6 text-muted-foreground/40" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
No collections yet. Create one to group items together.
|
||||
</p>
|
||||
</div>
|
||||
@@ -144,20 +174,28 @@ export function CollectionsPopover({ nodeId, collectionIds, children }: Collecti
|
||||
|
||||
if (isDeleting) {
|
||||
return (
|
||||
<li key={collection.id} className="flex items-center justify-between gap-2 px-3 py-2.5 bg-red-500/10">
|
||||
<span className="text-xs text-foreground/80 truncate">Delete "{collection.name}"?</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<li
|
||||
className="flex items-center justify-between gap-2 bg-red-500/10 px-3 py-2.5"
|
||||
key={collection.id}
|
||||
>
|
||||
<span className="truncate text-foreground/80 text-xs">
|
||||
Delete "{collection.name}"?
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
className="rounded-md bg-red-500/20 px-2 py-0.5 font-medium text-[11px] text-red-400 transition-colors hover:bg-red-500/30"
|
||||
onClick={() => {
|
||||
deleteCollection(collection.id)
|
||||
setDeletingId(null)
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => { deleteCollection(collection.id); setDeletingId(null) }}
|
||||
className="rounded-md px-2 py-0.5 text-[11px] font-medium bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md px-2 py-0.5 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-white/10"
|
||||
onClick={() => setDeletingId(null)}
|
||||
className="rounded-md px-2 py-0.5 text-[11px] font-medium hover:bg-white/10 text-muted-foreground transition-colors"
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -168,29 +206,29 @@ export function CollectionsPopover({ nodeId, collectionIds, children }: Collecti
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<li key={collection.id} className="flex items-center gap-1.5 px-3 py-2">
|
||||
<li className="flex items-center gap-1.5 px-3 py-2" key={collection.id}>
|
||||
<ColorDot color={renameColor || '#6366f1'} onChange={setRenameColor} />
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
className="min-w-0 flex-1 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-foreground text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameConfirm(collection.id)
|
||||
if (e.key === 'Escape') setRenamingId(null)
|
||||
}}
|
||||
className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
|
||||
value={renameValue}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 text-primary transition-colors hover:bg-primary/30"
|
||||
onClick={() => handleRenameConfirm(collection.id)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-white/10"
|
||||
onClick={() => setRenamingId(null)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -200,7 +238,7 @@ export function CollectionsPopover({ nodeId, collectionIds, children }: Collecti
|
||||
|
||||
return (
|
||||
<li key={collection.id}>
|
||||
<div className="group flex items-center gap-2 px-3 py-2 hover:bg-white/5 transition-colors">
|
||||
<div className="group flex items-center gap-2 px-3 py-2 transition-colors hover:bg-white/5">
|
||||
{/* Color dot — click to pick color */}
|
||||
<ColorDot
|
||||
color={collection.color ?? '#6366f1'}
|
||||
@@ -209,11 +247,16 @@ export function CollectionsPopover({ nodeId, collectionIds, children }: Collecti
|
||||
|
||||
{/* Name + count — clicking toggles membership */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
||||
onClick={() => toggleMembership(collection.id)}
|
||||
className="flex-1 min-w-0 flex items-center gap-1.5 text-left"
|
||||
type="button"
|
||||
>
|
||||
<span className={cn('truncate text-xs font-medium', isIn ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-medium text-xs',
|
||||
isIn ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{collection.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground/60">
|
||||
@@ -224,7 +267,7 @@ export function CollectionsPopover({ nodeId, collectionIds, children }: Collecti
|
||||
{/* Membership check */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors pointer-events-none',
|
||||
'pointer-events-none flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors',
|
||||
isIn ? 'border-primary bg-primary/20 text-primary' : 'border-border/50',
|
||||
)}
|
||||
>
|
||||
@@ -234,13 +277,15 @@ export function CollectionsPopover({ nodeId, collectionIds, children }: Collecti
|
||||
{/* Expand toggle (only if has members) */}
|
||||
{collection.nodeIds.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => toggleExpand(collection.id)}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground transition-colors"
|
||||
type="button"
|
||||
>
|
||||
{isExpanded
|
||||
? <ChevronDown className="h-3 w-3" />
|
||||
: <ChevronRight className="h-3 w-3" />}
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -248,18 +293,27 @@ export function CollectionsPopover({ nodeId, collectionIds, children }: Collecti
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground opacity-0 transition-colors hover:bg-white/10 hover:text-foreground group-hover:opacity-100"
|
||||
type="button"
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="left" align="start" className="min-w-40">
|
||||
<DropdownMenuItem onClick={() => { setRenamingId(collection.id); setRenameValue(collection.name); setRenameColor(collection.color ?? '') }}>
|
||||
<DropdownMenuContent align="start" className="min-w-40" side="left">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setRenamingId(collection.id)
|
||||
setRenameValue(collection.name)
|
||||
setRenameColor(collection.color ?? '')
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={() => setDeletingId(collection.id)}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeletingId(collection.id)}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -269,13 +323,20 @@ export function CollectionsPopover({ nodeId, collectionIds, children }: Collecti
|
||||
|
||||
{/* Expanded member list */}
|
||||
{isExpanded && (
|
||||
<ul className="pb-1 pl-6 pr-3 flex flex-col gap-0.5">
|
||||
<ul className="flex flex-col gap-0.5 pr-3 pb-1 pl-6">
|
||||
{collection.nodeIds.map((nid) => {
|
||||
const n = nodes[nid]
|
||||
return (
|
||||
<li key={nid} className="flex items-center gap-1.5 py-0.5">
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground/40 shrink-0" />
|
||||
<span className={cn('truncate text-[11px]', nid === nodeId ? 'text-foreground font-medium' : 'text-muted-foreground')}>
|
||||
<li className="flex items-center gap-1.5 py-0.5" key={nid}>
|
||||
<span className="h-1 w-1 shrink-0 rounded-full bg-muted-foreground/40" />
|
||||
<span
|
||||
className={cn(
|
||||
'truncate text-[11px]',
|
||||
nid === nodeId
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{n?.name ?? nid}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -4,18 +4,17 @@ import { type AnyNode, type AnyNodeId, DoorNode, emitter, useScene } from '@pasc
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { usePresetsAdapter } from '../../../contexts/presets-context'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { ToggleControl } from '../controls/toggle-control'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { ToggleControl } from '../controls/toggle-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { PresetsPopover } from './presets/presets-popover'
|
||||
import { usePresetsAdapter } from '../../../contexts/presets-context'
|
||||
|
||||
export function DoorPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
@@ -28,9 +27,7 @@ export function DoorPanel() {
|
||||
const adapter = usePresetsAdapter()
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId
|
||||
? (nodes[selectedId as AnyNode['id']] as DoorNode | undefined)
|
||||
: undefined
|
||||
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<DoorNode>) => {
|
||||
@@ -61,7 +58,7 @@ export function DoorPanel() {
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!selectedId || !node) return
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:item-delete')
|
||||
deleteNode(selectedId as AnyNode['id'])
|
||||
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
@@ -69,7 +66,7 @@ export function DoorPanel() {
|
||||
}, [selectedId, node, deleteNode, setSelection])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!node || !node.parentId) return
|
||||
if (!(node && node.parentId)) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useScene.temporal.getState().pause()
|
||||
const cloned = structuredClone(node) as any
|
||||
@@ -84,7 +81,7 @@ export function DoorPanel() {
|
||||
const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
|
||||
const numSegs = node!.segments.length
|
||||
const totalH = node!.segments.reduce((sum, s) => sum + s.heightRatio, 0)
|
||||
const normH = node!.segments.map(s => s.heightRatio / totalH)
|
||||
const normH = node!.segments.map((s) => s.heightRatio / totalH)
|
||||
const clamped = Math.max(0.05, Math.min(0.95, newVal))
|
||||
const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1
|
||||
const delta = clamped - normH[segIdx]!
|
||||
@@ -102,7 +99,7 @@ export function DoorPanel() {
|
||||
const seg = node!.segments[segIdx]!
|
||||
const normRatios = (() => {
|
||||
const sum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
||||
return seg.columnRatios.map(r => r / sum)
|
||||
return seg.columnRatios.map((r) => r / sum)
|
||||
})()
|
||||
const numCols = normRatios.length
|
||||
const clamped = Math.max(0.05, Math.min(0.95, newVal))
|
||||
@@ -142,51 +139,60 @@ export function DoorPanel() {
|
||||
}
|
||||
}, [node])
|
||||
|
||||
const handleSavePreset = useCallback(async (name: string) => {
|
||||
const data = getDoorPresetData()
|
||||
if (!data || !selectedId) return
|
||||
const presetId = await adapter.savePreset('door', name, data)
|
||||
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
|
||||
}, [getDoorPresetData, selectedId, adapter])
|
||||
const handleSavePreset = useCallback(
|
||||
async (name: string) => {
|
||||
const data = getDoorPresetData()
|
||||
if (!(data && selectedId)) return
|
||||
const presetId = await adapter.savePreset('door', name, data)
|
||||
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
|
||||
},
|
||||
[getDoorPresetData, selectedId, adapter],
|
||||
)
|
||||
|
||||
const handleOverwritePreset = useCallback(async (id: string) => {
|
||||
const data = getDoorPresetData()
|
||||
if (!data || !selectedId) return
|
||||
await adapter.overwritePreset('door', id, data)
|
||||
emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
|
||||
}, [getDoorPresetData, selectedId, adapter])
|
||||
const handleOverwritePreset = useCallback(
|
||||
async (id: string) => {
|
||||
const data = getDoorPresetData()
|
||||
if (!(data && selectedId)) return
|
||||
await adapter.overwritePreset('door', id, data)
|
||||
emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
|
||||
},
|
||||
[getDoorPresetData, selectedId, adapter],
|
||||
)
|
||||
|
||||
const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
|
||||
handleUpdate(data as Partial<DoorNode>)
|
||||
}, [handleUpdate])
|
||||
const handleApplyPreset = useCallback(
|
||||
(data: Record<string, unknown>) => {
|
||||
handleUpdate(data as Partial<DoorNode>)
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
if (!node || node.type !== 'door' || selectedIds.length !== 1) return null
|
||||
|
||||
const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
|
||||
const normHeights = node.segments.map(seg => seg.heightRatio / hSum)
|
||||
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
title={node.name || "Door"}
|
||||
icon="/icons/door.png"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Door'}
|
||||
width={320}
|
||||
>
|
||||
{/* Presets strip */}
|
||||
<div className="px-3 pt-2.5 pb-1.5 border-b border-border/30">
|
||||
<div className="border-border/30 border-b px-3 pt-2.5 pb-1.5">
|
||||
<PresetsPopover
|
||||
type="door"
|
||||
onApply={handleApplyPreset}
|
||||
onSave={handleSavePreset}
|
||||
onOverwrite={handleOverwritePreset}
|
||||
onFetchPresets={(tab) => adapter.fetchPresets('door', tab)}
|
||||
onRename={(id, name) => adapter.renamePreset(id, name)}
|
||||
onDelete={(id) => adapter.deletePreset(id)}
|
||||
onToggleCommunity={adapter.togglePresetCommunity}
|
||||
isAuthenticated={adapter.isAuthenticated}
|
||||
onApply={handleApplyPreset}
|
||||
onDelete={(id) => adapter.deletePreset(id)}
|
||||
onFetchPresets={(tab) => adapter.fetchPresets('door', tab)}
|
||||
onOverwrite={handleOverwritePreset}
|
||||
onRename={(id, name) => adapter.renamePreset(id, name)}
|
||||
onSave={handleSavePreset}
|
||||
onToggleCommunity={adapter.togglePresetCommunity}
|
||||
tabs={adapter.tabs}
|
||||
type="door"
|
||||
>
|
||||
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[#3e3e3e] transition-colors">
|
||||
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 font-medium text-muted-foreground text-xs transition-colors hover:bg-[#3e3e3e] hover:text-foreground">
|
||||
<BookMarked className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>Presets</span>
|
||||
</button>
|
||||
@@ -195,21 +201,25 @@ export function DoorPanel() {
|
||||
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">wall</sub></>}
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
||||
min={-10}
|
||||
label={
|
||||
<>
|
||||
X<sub className="ml-[1px] text-[11px] opacity-70">wall</sub>
|
||||
</>
|
||||
}
|
||||
max={10}
|
||||
min={-10}
|
||||
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
/>
|
||||
<div className="pt-2 pb-1 px-1">
|
||||
<ActionButton
|
||||
icon={<FlipHorizontal2 className="h-4 w-4" />}
|
||||
label="Flip Side"
|
||||
onClick={handleFlip}
|
||||
<div className="px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
className="w-full"
|
||||
icon={<FlipHorizontal2 className="h-4 w-4" />}
|
||||
label="Flip Side"
|
||||
onClick={handleFlip}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
@@ -217,94 +227,100 @@ export function DoorPanel() {
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Width"
|
||||
value={Math.round(node.width * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ width: v })}
|
||||
min={0.5}
|
||||
max={3}
|
||||
min={0.5}
|
||||
onChange={(v) => handleUpdate({ width: v })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.width * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })}
|
||||
min={1.0}
|
||||
max={4}
|
||||
min={1.0}
|
||||
onChange={(v) =>
|
||||
handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })
|
||||
}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Frame">
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
value={Math.round(node.frameThickness * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ frameThickness: v })}
|
||||
min={0.01}
|
||||
max={0.2}
|
||||
min={0.01}
|
||||
onChange={(v) => handleUpdate({ frameThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.frameThickness * 1000) / 1000}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
value={Math.round(node.frameDepth * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||
min={0.01}
|
||||
max={0.3}
|
||||
min={0.01}
|
||||
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.frameDepth * 1000) / 1000}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Content Padding">
|
||||
<SliderControl
|
||||
label="Horizontal"
|
||||
value={Math.round(node.contentPadding[0] * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
|
||||
min={0}
|
||||
max={0.2}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(node.contentPadding[0] * 1000) / 1000}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Vertical"
|
||||
value={Math.round(node.contentPadding[1] * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
|
||||
min={0}
|
||||
max={0.2}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(node.contentPadding[1] * 1000) / 1000}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Swing">
|
||||
<div className="flex flex-col gap-2 px-1 pb-1">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Hinges Side</span>
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Hinges Side
|
||||
</span>
|
||||
<SegmentedControl
|
||||
value={node.hingesSide}
|
||||
onChange={(v) => handleUpdate({ hingesSide: v })}
|
||||
options={[
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
]}
|
||||
value={node.hingesSide}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Direction</span>
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Direction
|
||||
</span>
|
||||
<SegmentedControl
|
||||
value={node.swingDirection}
|
||||
onChange={(v) => handleUpdate({ swingDirection: v })}
|
||||
options={[
|
||||
{ label: 'Inward', value: 'inward' },
|
||||
{ label: 'Outward', value: 'outward' },
|
||||
]}
|
||||
value={node.swingDirection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,21 +328,21 @@ export function DoorPanel() {
|
||||
|
||||
<PanelSection title="Threshold">
|
||||
<ToggleControl
|
||||
label="Enable Threshold"
|
||||
checked={node.threshold}
|
||||
label="Enable Threshold"
|
||||
onChange={(checked) => handleUpdate({ threshold: checked })}
|
||||
/>
|
||||
{node.threshold && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(node.thresholdHeight * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ thresholdHeight: v })}
|
||||
min={0.005}
|
||||
max={0.1}
|
||||
min={0.005}
|
||||
onChange={(v) => handleUpdate({ thresholdHeight: v })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(node.thresholdHeight * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -334,31 +350,33 @@ export function DoorPanel() {
|
||||
|
||||
<PanelSection title="Handle">
|
||||
<ToggleControl
|
||||
label="Enable Handle"
|
||||
checked={node.handle}
|
||||
label="Enable Handle"
|
||||
onChange={(checked) => handleUpdate({ handle: checked })}
|
||||
/>
|
||||
{node.handle && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(node.handleHeight * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ handleHeight: v })}
|
||||
min={0.5}
|
||||
max={node.height - 0.1}
|
||||
min={0.5}
|
||||
onChange={(v) => handleUpdate({ handleHeight: v })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.handleHeight * 100) / 100}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Handle Side</span>
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Handle Side
|
||||
</span>
|
||||
<SegmentedControl
|
||||
value={node.handleSide}
|
||||
onChange={(v) => handleUpdate({ handleSide: v })}
|
||||
options={[
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
]}
|
||||
value={node.handleSide}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -367,26 +385,26 @@ export function DoorPanel() {
|
||||
|
||||
<PanelSection title="Hardware">
|
||||
<ToggleControl
|
||||
label="Door Closer"
|
||||
checked={node.doorCloser}
|
||||
label="Door Closer"
|
||||
onChange={(checked) => handleUpdate({ doorCloser: checked })}
|
||||
/>
|
||||
<ToggleControl
|
||||
label="Panic Bar"
|
||||
checked={node.panicBar}
|
||||
label="Panic Bar"
|
||||
onChange={(checked) => handleUpdate({ panicBar: checked })}
|
||||
/>
|
||||
{node.panicBar && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Bar Height"
|
||||
value={Math.round(node.panicBarHeight * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ panicBarHeight: v })}
|
||||
min={0.5}
|
||||
max={node.height - 0.1}
|
||||
min={0.5}
|
||||
onChange={(v) => handleUpdate({ panicBarHeight: v })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.panicBarHeight * 100) / 100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -396,17 +414,16 @@ export function DoorPanel() {
|
||||
{node.segments.map((seg, i) => {
|
||||
const numCols = seg.columnRatios.length
|
||||
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
||||
const normCols = seg.columnRatios.map(r => r / colSum)
|
||||
const normCols = seg.columnRatios.map((r) => r / colSum)
|
||||
return (
|
||||
<div key={i} className="mb-2 flex flex-col gap-1">
|
||||
<div className="mb-2 flex flex-col gap-1" key={i}>
|
||||
<div className="flex items-center justify-between pb-1">
|
||||
<span className="text-xs font-medium text-white/80">Segment {i + 1}</span>
|
||||
<span className="font-medium text-white/80 text-xs">Segment {i + 1}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<SegmentedControl
|
||||
value={seg.type}
|
||||
onChange={(t) => {
|
||||
const updated = node.segments.map((s, idx) => idx === i ? { ...s, type: t } : s)
|
||||
const updated = node.segments.map((s, idx) => (idx === i ? { ...s, type: t } : s))
|
||||
handleUpdate({ segments: updated })
|
||||
}}
|
||||
options={[
|
||||
@@ -414,22 +431,24 @@ export function DoorPanel() {
|
||||
{ label: 'Glass', value: 'glass' },
|
||||
{ label: 'Empty', value: 'empty' },
|
||||
]}
|
||||
value={seg.type}
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(normHeights[i]! * 100 * 10) / 10}
|
||||
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
|
||||
min={5}
|
||||
max={95}
|
||||
min={5}
|
||||
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
value={Math.round(normHeights[i]! * 100 * 10) / 10}
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
label="Columns"
|
||||
value={numCols}
|
||||
max={8}
|
||||
min={1}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
const updated = node.segments.map((s, idx) =>
|
||||
@@ -437,76 +456,75 @@ export function DoorPanel() {
|
||||
)
|
||||
handleUpdate({ segments: updated })
|
||||
}}
|
||||
min={1}
|
||||
max={8}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={numCols}
|
||||
/>
|
||||
|
||||
{numCols > 1 && (
|
||||
<div className="mt-1 border-t border-border/50 pt-1">
|
||||
<div className="mt-1 border-border/50 border-t pt-1">
|
||||
{normCols.map((ratio, ci) => (
|
||||
<SliderControl
|
||||
key={`c-${ci}`}
|
||||
label={`C${ci + 1}`}
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
|
||||
min={5}
|
||||
max={95}
|
||||
min={5}
|
||||
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
/>
|
||||
))}
|
||||
<SliderControl
|
||||
label="Divider"
|
||||
value={Math.round(seg.dividerThickness * 1000) / 1000}
|
||||
max={0.1}
|
||||
min={0.005}
|
||||
onChange={(v) => {
|
||||
const updated = node.segments.map((s, idx) =>
|
||||
idx === i ? { ...s, dividerThickness: v } : s,
|
||||
)
|
||||
handleUpdate({ segments: updated })
|
||||
}}
|
||||
min={0.005}
|
||||
max={0.1}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(seg.dividerThickness * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{seg.type === 'panel' && (
|
||||
<div className="mt-1 border-t border-border/50 pt-1">
|
||||
<div className="mt-1 border-border/50 border-t pt-1">
|
||||
<SliderControl
|
||||
label="Inset"
|
||||
value={Math.round(seg.panelInset * 1000) / 1000}
|
||||
max={0.1}
|
||||
min={0}
|
||||
onChange={(v) => {
|
||||
const updated = node.segments.map((s, idx) =>
|
||||
idx === i ? { ...s, panelInset: v } : s,
|
||||
)
|
||||
handleUpdate({ segments: updated })
|
||||
}}
|
||||
min={0}
|
||||
max={0.1}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(seg.panelInset * 1000) / 1000}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
value={Math.round(seg.panelDepth * 1000) / 1000}
|
||||
max={0.1}
|
||||
min={0}
|
||||
onChange={(v) => {
|
||||
const updated = node.segments.map((s, idx) =>
|
||||
idx === i ? { ...s, panelDepth: v } : s,
|
||||
)
|
||||
handleUpdate({ segments: updated })
|
||||
}}
|
||||
min={0}
|
||||
max={0.1}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(seg.panelDepth * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -515,21 +533,28 @@ export function DoorPanel() {
|
||||
})}
|
||||
|
||||
<div className="flex gap-1.5 px-1 pt-1">
|
||||
<ActionButton
|
||||
label="+ Add Segment"
|
||||
<ActionButton
|
||||
label="+ Add Segment"
|
||||
onClick={() => {
|
||||
const updated = [
|
||||
...node.segments,
|
||||
{ type: 'panel' as const, heightRatio: 1, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 },
|
||||
{
|
||||
type: 'panel' as const,
|
||||
heightRatio: 1,
|
||||
columnRatios: [1],
|
||||
dividerThickness: 0.03,
|
||||
panelDepth: 0.01,
|
||||
panelInset: 0.04,
|
||||
},
|
||||
]
|
||||
handleUpdate({ segments: updated })
|
||||
}}
|
||||
/>
|
||||
{node.segments.length > 1 && (
|
||||
<ActionButton
|
||||
label="- Remove"
|
||||
onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
|
||||
<ActionButton
|
||||
className="text-white/60 hover:text-white"
|
||||
label="- Remove"
|
||||
onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -538,12 +563,16 @@ export function DoorPanel() {
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
|
||||
<ActionButton
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
<ActionButton
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<ActionButton
|
||||
className="hover:bg-red-500/20"
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { getScaledDimensions, type AnyNode, ItemNode, useScene } from '@pascal-app/core'
|
||||
import { type AnyNode, getScaledDimensions, ItemNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Link, Link2Off, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { cn } from '../../../lib/utils'
|
||||
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { CollectionsPopover } from './collections/collections-popover'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
export function ItemPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
@@ -24,15 +22,13 @@ export function ItemPanel() {
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId
|
||||
? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined)
|
||||
: undefined
|
||||
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined) : undefined
|
||||
|
||||
const [uniformScale, setUniformScale] = useState(true)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<ItemNode>) => {
|
||||
if (!selectedId || !node) return
|
||||
if (!(selectedId && node)) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
|
||||
if (node.asset.attachTo === 'wall' && node.parentId) {
|
||||
@@ -83,143 +79,189 @@ export function ItemPanel() {
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
title={node.name || node.asset.name}
|
||||
icon={node.asset.thumbnail || '/icons/furniture.png'}
|
||||
onClose={handleClose}
|
||||
title={node.name || node.asset.name}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
onChange={(value) => handleUpdate({ position: [value, node.position[1], node.position[2]] })}
|
||||
min={node.position[0] - 2}
|
||||
label={
|
||||
<>
|
||||
X<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
max={node.position[0] + 2}
|
||||
min={node.position[0] - 2}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ position: [value, node.position[1], node.position[2]] })
|
||||
}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
onChange={(value) => handleUpdate({ position: [node.position[0], value, node.position[2]] })}
|
||||
min={node.position[1] - 2}
|
||||
label={
|
||||
<>
|
||||
Y<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
max={node.position[1] + 2}
|
||||
min={node.position[1] - 2}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ position: [node.position[0], value, node.position[2]] })
|
||||
}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
onChange={(value) => handleUpdate({ position: [node.position[0], node.position[1], value] })}
|
||||
min={node.position[2] - 2}
|
||||
label={
|
||||
<>
|
||||
Z<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
max={node.position[2] + 2}
|
||||
min={node.position[2] - 2}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ position: [node.position[0], node.position[1], value] })
|
||||
}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Rotation">
|
||||
<SliderControl
|
||||
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
|
||||
value={Math.round((node.rotation[1] * 180) / Math.PI)}
|
||||
label={
|
||||
<>
|
||||
Y<sub className="ml-[1px] text-[11px] opacity-70">rot</sub>
|
||||
</>
|
||||
}
|
||||
max={Math.round((node.rotation[1] * 180) / Math.PI) + 45}
|
||||
min={Math.round((node.rotation[1] * 180) / Math.PI) - 45}
|
||||
onChange={(degrees) => {
|
||||
const radians = (degrees * Math.PI) / 180
|
||||
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
|
||||
}}
|
||||
min={Math.round((node.rotation[1] * 180) / Math.PI) - 45}
|
||||
max={Math.round((node.rotation[1] * 180) / Math.PI) + 45}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round((node.rotation[1] * 180) / Math.PI)}
|
||||
/>
|
||||
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
label="-45°"
|
||||
<ActionButton
|
||||
label="-45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
||||
const radians = ((currentDegrees - 45) * Math.PI) / 180
|
||||
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
|
||||
}}
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+45°"
|
||||
<ActionButton
|
||||
label="+45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
||||
const radians = ((currentDegrees + 45) * Math.PI) / 180
|
||||
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
|
||||
}}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Scale">
|
||||
<div className="flex items-center justify-between px-2 pb-2">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Uniform Scale</span>
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Uniform Scale
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-md transition-colors text-muted-foreground hover:text-foreground",
|
||||
uniformScale ? "bg-[#3e3e3e]" : "bg-[#2C2C2E] hover:bg-[#3e3e3e]"
|
||||
'flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground',
|
||||
uniformScale ? 'bg-[#3e3e3e]' : 'bg-[#2C2C2E] hover:bg-[#3e3e3e]',
|
||||
)}
|
||||
onClick={() => setUniformScale((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
{uniformScale ? <Link className="h-3.5 w-3.5" /> : <Link2Off className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
{uniformScale ? (
|
||||
<SliderControl
|
||||
label={<>XYZ<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
|
||||
value={Math.round(node.scale[0] * 100) / 100}
|
||||
label={
|
||||
<>
|
||||
XYZ<sub className="ml-[1px] text-[11px] opacity-70">scale</sub>
|
||||
</>
|
||||
}
|
||||
max={10}
|
||||
min={0.01}
|
||||
onChange={(value) => {
|
||||
const v = Math.max(0.01, value)
|
||||
handleUpdate({ scale: [v, v, v] })
|
||||
}}
|
||||
min={0.01}
|
||||
max={10}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
value={Math.round(node.scale[0] * 100) / 100}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SliderControl
|
||||
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
|
||||
label={
|
||||
<>
|
||||
X<sub className="ml-[1px] text-[11px] opacity-70">scale</sub>
|
||||
</>
|
||||
}
|
||||
max={10}
|
||||
min={0.01}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] })
|
||||
}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
value={Math.round(node.scale[0] * 100) / 100}
|
||||
onChange={(value) => handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] })}
|
||||
min={0.01}
|
||||
max={10}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
|
||||
label={
|
||||
<>
|
||||
Y<sub className="ml-[1px] text-[11px] opacity-70">scale</sub>
|
||||
</>
|
||||
}
|
||||
max={10}
|
||||
min={0.01}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] })
|
||||
}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
value={Math.round(node.scale[1] * 100) / 100}
|
||||
onChange={(value) => handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] })}
|
||||
min={0.01}
|
||||
max={10}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
|
||||
value={Math.round(node.scale[2] * 100) / 100}
|
||||
onChange={(value) => handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] })}
|
||||
min={0.01}
|
||||
label={
|
||||
<>
|
||||
Z<sub className="ml-[1px] text-[11px] opacity-70">scale</sub>
|
||||
</>
|
||||
}
|
||||
max={10}
|
||||
min={0.01}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] })
|
||||
}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
value={Math.round(node.scale[2] * 100) / 100}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Info">
|
||||
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between px-2 py-1 text-muted-foreground text-sm">
|
||||
<span>Dimensions</span>
|
||||
{(() => {
|
||||
const [w, h, d] = getScaledDimensions(node)
|
||||
@@ -234,7 +276,10 @@ export function ItemPanel() {
|
||||
|
||||
<PanelSection title="Collections">
|
||||
<ActionGroup>
|
||||
<CollectionsPopover nodeId={selectedId as AnyNode['id']} collectionIds={node.collectionIds}>
|
||||
<CollectionsPopover
|
||||
collectionIds={node.collectionIds}
|
||||
nodeId={selectedId as AnyNode['id']}
|
||||
>
|
||||
<ActionButton label="Manage collections…" />
|
||||
</CollectionsPopover>
|
||||
</ActionGroup>
|
||||
@@ -243,12 +288,16 @@ export function ItemPanel() {
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
|
||||
<ActionButton
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<ActionButton
|
||||
className="hover:bg-red-500/20"
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
className="hover:bg-red-500/20"
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CeilingPanel } from './ceiling-panel'
|
||||
import { DoorPanel } from './door-panel'
|
||||
import { ItemPanel } from './item-panel'
|
||||
import { ReferencePanel } from './reference-panel'
|
||||
import { RoofPanel } from './roof-panel'
|
||||
import { SlabPanel } from './slab-panel'
|
||||
import { WallPanel } from './wall-panel'
|
||||
import { DoorPanel } from './door-panel'
|
||||
import { WindowPanel } from './window-panel'
|
||||
|
||||
export function PanelManager() {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { X, RotateCcw, Moon } from 'lucide-react'
|
||||
import { Moon, RotateCcw, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { cn } from '../../../lib/utils'
|
||||
|
||||
interface PanelWrapperProps {
|
||||
title: string
|
||||
@@ -24,45 +24,37 @@ export function PanelWrapper({
|
||||
width = 320, // default width
|
||||
}: PanelWrapperProps) {
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto fixed right-4 top-20 z-50 flex flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground max-h-[calc(100dvh-100px)]",
|
||||
className
|
||||
'pointer-events-auto fixed top-20 right-4 z-50 flex max-h-[calc(100dvh-100px)] flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground',
|
||||
className,
|
||||
)}
|
||||
style={{ width }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-border/50">
|
||||
<div className="flex items-center justify-between border-border/50 border-b px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon && (
|
||||
<Image
|
||||
src={icon}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="shrink-0 object-contain"
|
||||
/>
|
||||
<Image alt="" className="shrink-0 object-contain" height={16} src={icon} width={16} />
|
||||
)}
|
||||
<h2 className="font-semibold text-foreground text-sm truncate tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
<h2 className="truncate font-semibold text-foreground text-sm tracking-tight">{title}</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{onReset && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
|
||||
onClick={onReset}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors bg-[#2C2C2E] hover:bg-[#3e3e3e] hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
|
||||
onClick={onClose}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors bg-[#2C2C2E] hover:bg-[#3e3e3e] hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -71,9 +63,7 @@ export function PanelWrapper({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 no-scrollbar flex flex-col">
|
||||
{children}
|
||||
</div>
|
||||
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { BookMarked, Check, Globe, GlobeLock, MoreHorizontal, Pencil, Plus, Save, Trash2, Users, X } from 'lucide-react'
|
||||
import { emitter } from '@pascal-app/core'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../primitives/dropdown-menu'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../../primitives/popover'
|
||||
import { cn } from '../../../../lib/utils'
|
||||
import {
|
||||
BookMarked,
|
||||
Check,
|
||||
Globe,
|
||||
GlobeLock,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { PresetsTab } from '../../../../contexts/presets-context'
|
||||
import { cn } from '../../../../lib/utils'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../primitives/dropdown-menu'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../../primitives/popover'
|
||||
|
||||
export type PresetType = 'door' | 'window'
|
||||
|
||||
@@ -133,26 +150,29 @@ export function PresetsPopover({
|
||||
const showTabs = tabs.length > 1
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="left"
|
||||
align="start"
|
||||
className="w-72 overflow-hidden rounded-xl border-border/50 bg-sidebar/95 p-0 shadow-2xl backdrop-blur-xl"
|
||||
side="left"
|
||||
sideOffset={8}
|
||||
className="w-72 p-0 border-border/50 bg-sidebar/95 backdrop-blur-xl shadow-2xl rounded-xl overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border/50">
|
||||
<div className="flex items-center justify-between border-border/50 border-b px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BookMarked className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold text-foreground tracking-tight">
|
||||
<span className="font-semibold text-foreground text-xs tracking-tight">
|
||||
{type === 'door' ? 'Door' : 'Window'} Presets
|
||||
</span>
|
||||
</div>
|
||||
{isAuthenticated && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
|
||||
onClick={() => {
|
||||
setShowSaveInput((v) => !v)
|
||||
setSaveName('')
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => { setShowSaveInput((v) => !v); setSaveName('') }}
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Save new
|
||||
@@ -161,30 +181,36 @@ export function PresetsPopover({
|
||||
</div>
|
||||
|
||||
{showSaveInput && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50 bg-white/5">
|
||||
<div className="flex items-center gap-1.5 border-border/50 border-b bg-white/5 px-3 py-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={saveName}
|
||||
className="min-w-0 flex-1 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-foreground text-xs outline-none placeholder:text-muted-foreground/60 focus:border-ring focus:ring-1 focus:ring-ring/30"
|
||||
onChange={(e) => setSaveName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveNew()
|
||||
if (e.key === 'Escape') { setShowSaveInput(false); setSaveName('') }
|
||||
if (e.key === 'Escape') {
|
||||
setShowSaveInput(false)
|
||||
setSaveName('')
|
||||
}
|
||||
}}
|
||||
placeholder="Preset name…"
|
||||
className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground/60 outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
|
||||
value={saveName}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 text-primary transition-colors hover:bg-primary/30 disabled:opacity-40"
|
||||
disabled={!saveName.trim() || saving}
|
||||
onClick={handleSaveNew}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary disabled:opacity-40 transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-white/10"
|
||||
onClick={() => {
|
||||
setShowSaveInput(false)
|
||||
setSaveName('')
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => { setShowSaveInput(false); setSaveName('') }}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -192,7 +218,7 @@ export function PresetsPopover({
|
||||
)}
|
||||
|
||||
{showTabs && (
|
||||
<div className="flex border-b border-border/50">
|
||||
<div className="flex border-border/50 border-b">
|
||||
{tabs.includes('community') && (
|
||||
<TabButton active={tab === 'community'} onClick={() => setTab('community')}>
|
||||
<Users className="h-3 w-3" />
|
||||
@@ -202,8 +228,10 @@ export function PresetsPopover({
|
||||
{tabs.includes('mine') && (
|
||||
<TabButton
|
||||
active={tab === 'mine'}
|
||||
onClick={() => { if (isAuthenticated) setTab('mine') }}
|
||||
disabled={!isAuthenticated}
|
||||
onClick={() => {
|
||||
if (isAuthenticated) setTab('mine')
|
||||
}}
|
||||
>
|
||||
<BookMarked className="h-3 w-3" />
|
||||
My presets
|
||||
@@ -212,35 +240,41 @@ export function PresetsPopover({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-72 overflow-y-auto no-scrollbar">
|
||||
<div className="no-scrollbar max-h-72 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
||||
</div>
|
||||
) : presets.length === 0 ? (
|
||||
<EmptyState tab={tab} isAuthenticated={isAuthenticated} />
|
||||
<EmptyState isAuthenticated={isAuthenticated} tab={tab} />
|
||||
) : (
|
||||
<ul className="divide-y divide-border/30">
|
||||
{presets.map((preset) => (
|
||||
<PresetRow
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isMine={tab === 'mine'}
|
||||
showCommunityToggle={!!onToggleCommunity}
|
||||
renamingId={renamingId}
|
||||
renameValue={renameValue}
|
||||
deletingId={deletingId}
|
||||
overwrittenId={overwrittenId}
|
||||
onApply={() => { onApply(preset.data); setOpen(false) }}
|
||||
isMine={tab === 'mine'}
|
||||
key={preset.id}
|
||||
onApply={() => {
|
||||
onApply(preset.data)
|
||||
setOpen(false)
|
||||
}}
|
||||
onDeleteCancel={() => setDeletingId(null)}
|
||||
onDeleteConfirm={() => handleDelete(preset.id)}
|
||||
onDeleteRequest={() => setDeletingId(preset.id)}
|
||||
onOverwrite={() => handleOverwrite(preset.id)}
|
||||
onToggleCommunity={() => handleToggleCommunity(preset.id, preset.is_community)}
|
||||
onStartRename={() => { setRenamingId(preset.id); setRenameValue(preset.name) }}
|
||||
onRenameCancel={() => setRenamingId(null)}
|
||||
onRenameChange={setRenameValue}
|
||||
onRenameConfirm={() => handleRename(preset.id)}
|
||||
onRenameCancel={() => setRenamingId(null)}
|
||||
onDeleteRequest={() => setDeletingId(preset.id)}
|
||||
onDeleteConfirm={() => handleDelete(preset.id)}
|
||||
onDeleteCancel={() => setDeletingId(null)}
|
||||
onStartRename={() => {
|
||||
setRenamingId(preset.id)
|
||||
setRenameValue(preset.name)
|
||||
}}
|
||||
onToggleCommunity={() => handleToggleCommunity(preset.id, preset.is_community)}
|
||||
overwrittenId={overwrittenId}
|
||||
preset={preset}
|
||||
renameValue={renameValue}
|
||||
renamingId={renamingId}
|
||||
showCommunityToggle={!!onToggleCommunity}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -252,20 +286,28 @@ export function PresetsPopover({
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active, onClick, disabled, children,
|
||||
active,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
active: boolean; onClick: () => void; disabled?: boolean; children: React.ReactNode
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-1.5 py-2 text-[11px] font-medium transition-colors',
|
||||
active ? 'text-foreground border-b-2 border-primary -mb-px' : 'text-muted-foreground hover:text-foreground',
|
||||
disabled && 'opacity-40 cursor-not-allowed',
|
||||
'flex flex-1 items-center justify-center gap-1.5 py-2 font-medium text-[11px] transition-colors',
|
||||
active
|
||||
? '-mb-px border-primary border-b-2 text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
disabled && 'cursor-not-allowed opacity-40',
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -274,9 +316,9 @@ function TabButton({
|
||||
|
||||
function EmptyState({ tab, isAuthenticated }: { tab: PresetsTab; isAuthenticated: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center px-4">
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center">
|
||||
<BookMarked className="h-6 w-6 text-muted-foreground/40" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{tab === 'community'
|
||||
? 'No community presets yet.'
|
||||
: isAuthenticated
|
||||
@@ -308,9 +350,23 @@ interface PresetRowProps {
|
||||
}
|
||||
|
||||
function PresetRow({
|
||||
preset, isMine, showCommunityToggle, renamingId, renameValue, deletingId, overwrittenId,
|
||||
onApply, onOverwrite, onToggleCommunity, onStartRename, onRenameChange, onRenameConfirm,
|
||||
onRenameCancel, onDeleteRequest, onDeleteConfirm, onDeleteCancel,
|
||||
preset,
|
||||
isMine,
|
||||
showCommunityToggle,
|
||||
renamingId,
|
||||
renameValue,
|
||||
deletingId,
|
||||
overwrittenId,
|
||||
onApply,
|
||||
onOverwrite,
|
||||
onToggleCommunity,
|
||||
onStartRename,
|
||||
onRenameChange,
|
||||
onRenameConfirm,
|
||||
onRenameCancel,
|
||||
onDeleteRequest,
|
||||
onDeleteConfirm,
|
||||
onDeleteCancel,
|
||||
}: PresetRowProps) {
|
||||
const isRenaming = renamingId === preset.id
|
||||
const isDeleting = deletingId === preset.id
|
||||
@@ -318,11 +374,23 @@ function PresetRow({
|
||||
|
||||
if (isDeleting) {
|
||||
return (
|
||||
<li className="flex items-center justify-between gap-2 px-3 py-2.5 bg-red-500/10">
|
||||
<span className="text-xs text-foreground/80 truncate">Delete "{preset.name}"?</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button type="button" onClick={onDeleteConfirm} className="rounded-md px-2 py-0.5 text-[11px] font-medium bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors">Delete</button>
|
||||
<button type="button" onClick={onDeleteCancel} className="rounded-md px-2 py-0.5 text-[11px] font-medium hover:bg-white/10 text-muted-foreground transition-colors">Cancel</button>
|
||||
<li className="flex items-center justify-between gap-2 bg-red-500/10 px-3 py-2.5">
|
||||
<span className="truncate text-foreground/80 text-xs">Delete "{preset.name}"?</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
className="rounded-md bg-red-500/20 px-2 py-0.5 font-medium text-[11px] text-red-400 transition-colors hover:bg-red-500/30"
|
||||
onClick={onDeleteConfirm}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md px-2 py-0.5 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-white/10"
|
||||
onClick={onDeleteCancel}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
@@ -333,58 +401,108 @@ function PresetRow({
|
||||
<li className="flex items-center gap-1.5 px-3 py-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
className="min-w-0 flex-1 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-foreground text-xs outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
|
||||
onChange={(e) => onRenameChange(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') onRenameConfirm(); if (e.key === 'Escape') onRenameCancel() }}
|
||||
className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onRenameConfirm()
|
||||
if (e.key === 'Escape') onRenameCancel()
|
||||
}}
|
||||
value={renameValue}
|
||||
/>
|
||||
<button type="button" onClick={onRenameConfirm} className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary transition-colors"><Check className="h-3.5 w-3.5" /></button>
|
||||
<button type="button" onClick={onRenameCancel} className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"><X className="h-3.5 w-3.5" /></button>
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 text-primary transition-colors hover:bg-primary/30"
|
||||
onClick={onRenameConfirm}
|
||||
type="button"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-white/10"
|
||||
onClick={onRenameCancel}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="group flex items-center gap-2 px-3 py-2.5 hover:bg-white/5 transition-colors">
|
||||
<div className="h-12 w-12 shrink-0 rounded-md border border-border/40 bg-white/5 overflow-hidden">
|
||||
<li className="group flex items-center gap-2 px-3 py-2.5 transition-colors hover:bg-white/5">
|
||||
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-md border border-border/40 bg-white/5">
|
||||
{preset.thumbnail_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={preset.thumbnail_url} alt={preset.name} className="h-full w-full object-cover" />
|
||||
<img
|
||||
alt={preset.name}
|
||||
className="h-full w-full object-cover"
|
||||
src={preset.thumbnail_url}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-3 w-5 rounded-sm border border-muted-foreground/30" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={onApply} className="flex-1 min-w-0 text-left">
|
||||
<button className="min-w-0 flex-1 text-left" onClick={onApply} type="button">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="block truncate text-xs font-medium text-foreground group-hover:text-foreground/90">{preset.name}</span>
|
||||
{isMine && preset.is_community && <Globe className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />}
|
||||
<span className="block truncate font-medium text-foreground text-xs group-hover:text-foreground/90">
|
||||
{preset.name}
|
||||
</span>
|
||||
{isMine && preset.is_community && (
|
||||
<Globe className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
|
||||
)}
|
||||
</span>
|
||||
<span className="block text-[10px] text-muted-foreground/60">
|
||||
{new Date(preset.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="block text-[10px] text-muted-foreground/60">{new Date(preset.created_at).toLocaleDateString()}</span>
|
||||
</button>
|
||||
{isMine && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors opacity-0 group-hover:opacity-100',
|
||||
justOverwritten ? 'text-green-400 bg-green-500/10 opacity-100' : 'text-muted-foreground hover:text-foreground hover:bg-white/10',
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-md opacity-0 transition-colors group-hover:opacity-100',
|
||||
justOverwritten
|
||||
? 'bg-green-500/10 text-green-400 opacity-100'
|
||||
: 'text-muted-foreground hover:bg-white/10 hover:text-foreground',
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
{justOverwritten ? <Check className="h-3 w-3" /> : <MoreHorizontal className="h-3.5 w-3.5" />}
|
||||
{justOverwritten ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="left" align="start" className="min-w-44">
|
||||
<DropdownMenuItem onClick={onOverwrite}><Save className="h-3.5 w-3.5" />Update with current</DropdownMenuItem>
|
||||
<DropdownMenuContent align="start" className="min-w-44" side="left">
|
||||
<DropdownMenuItem onClick={onOverwrite}>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
Update with current
|
||||
</DropdownMenuItem>
|
||||
{showCommunityToggle && (
|
||||
<DropdownMenuItem onClick={onToggleCommunity}>
|
||||
{preset.is_community ? <><GlobeLock className="h-3.5 w-3.5" />Remove from community</> : <><Globe className="h-3.5 w-3.5" />Share with community</>}
|
||||
{preset.is_community ? (
|
||||
<>
|
||||
<GlobeLock className="h-3.5 w-3.5" />
|
||||
Remove from community
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
Share with community
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={onStartRename}><Pencil className="h-3.5 w-3.5" />Rename</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={onDeleteRequest}><Trash2 className="h-3.5 w-3.5" />Delete</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onStartRename}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDeleteRequest} variant="destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
@@ -4,12 +4,11 @@ import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-a
|
||||
import { Box, Image as ImageIcon } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
type ReferenceNode = ScanNode | GuideNode
|
||||
|
||||
@@ -41,108 +40,136 @@ export function ReferencePanel() {
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
title={node.name || (isScan ? '3D Scan' : 'Guide Image')}
|
||||
icon={isScan ? undefined : undefined}
|
||||
onClose={handleClose}
|
||||
title={node.name || (isScan ? '3D Scan' : 'Guide Image')}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
label={
|
||||
<>
|
||||
X<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(value) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[0] = value
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
min={-50}
|
||||
max={50}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
label={
|
||||
<>
|
||||
Y<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(value) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[1] = value
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
min={-50}
|
||||
max={50}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
label={
|
||||
<>
|
||||
Z<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(value) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[2] = value
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
min={-50}
|
||||
max={50}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Rotation">
|
||||
<SliderControl
|
||||
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
|
||||
value={Math.round((node.rotation[1] * 180) / Math.PI)}
|
||||
label={
|
||||
<>
|
||||
Y<sub className="ml-[1px] text-[11px] opacity-70">rot</sub>
|
||||
</>
|
||||
}
|
||||
max={180}
|
||||
min={-180}
|
||||
onChange={(degrees) => {
|
||||
const radians = (degrees * Math.PI) / 180
|
||||
handleUpdate({
|
||||
rotation: [node.rotation[0], radians, node.rotation[2]],
|
||||
})
|
||||
}}
|
||||
min={-180}
|
||||
max={180}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round((node.rotation[1] * 180) / Math.PI)}
|
||||
/>
|
||||
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
label="-45°"
|
||||
onClick={() => handleUpdate({ rotation: [node.rotation[0], node.rotation[1] - Math.PI / 4, node.rotation[2]] })}
|
||||
<ActionButton
|
||||
label="-45°"
|
||||
onClick={() =>
|
||||
handleUpdate({
|
||||
rotation: [node.rotation[0], node.rotation[1] - Math.PI / 4, node.rotation[2]],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+45°"
|
||||
onClick={() => handleUpdate({ rotation: [node.rotation[0], node.rotation[1] + Math.PI / 4, node.rotation[2]] })}
|
||||
<ActionButton
|
||||
label="+45°"
|
||||
onClick={() =>
|
||||
handleUpdate({
|
||||
rotation: [node.rotation[0], node.rotation[1] + Math.PI / 4, node.rotation[2]],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Scale & Opacity">
|
||||
<SliderControl
|
||||
label={<>XYZ<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
|
||||
value={Math.round(node.scale * 100) / 100}
|
||||
label={
|
||||
<>
|
||||
XYZ<sub className="ml-[1px] text-[11px] opacity-70">scale</sub>
|
||||
</>
|
||||
}
|
||||
max={10}
|
||||
min={0.01}
|
||||
onChange={(value) => {
|
||||
if (value > 0) {
|
||||
handleUpdate({ scale: value })
|
||||
}
|
||||
}}
|
||||
min={0.01}
|
||||
max={10}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
value={Math.round(node.scale * 100) / 100}
|
||||
/>
|
||||
|
||||
|
||||
<SliderControl
|
||||
label="Opacity"
|
||||
value={node.opacity}
|
||||
onChange={(v) => handleUpdate({ opacity: v })}
|
||||
min={0}
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ opacity: v })}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="%"
|
||||
value={node.opacity}
|
||||
/>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { ActionButton } from '../controls/action-button'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { ActionButton } from '../controls/action-button'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
export function RoofPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
@@ -17,9 +16,7 @@ export function RoofPanel() {
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId
|
||||
? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined)
|
||||
: undefined
|
||||
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<RoofNode>) => {
|
||||
@@ -39,129 +36,145 @@ export function RoofPanel() {
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
title={node.name || "Roof"}
|
||||
icon="/icons/roof.png"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Roof'}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Length"
|
||||
value={Math.round(node.length * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ length: v })}
|
||||
min={0.5}
|
||||
max={20}
|
||||
min={0.5}
|
||||
onChange={(v) => handleUpdate({ length: v })}
|
||||
precision={2}
|
||||
step={0.5}
|
||||
unit="m"
|
||||
value={Math.round(node.length * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ height: v })}
|
||||
min={0.1}
|
||||
max={10}
|
||||
min={0.1}
|
||||
onChange={(v) => handleUpdate({ height: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Slope Widths">
|
||||
<div className="flex items-center justify-between px-2 pb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">
|
||||
<div className="flex items-center justify-between px-2 pb-2 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
<span>Widths</span>
|
||||
<span>Total: {totalWidth.toFixed(1)}m</span>
|
||||
</div>
|
||||
<SliderControl
|
||||
label="Left"
|
||||
value={Math.round(node.leftWidth * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ leftWidth: v })}
|
||||
min={0.1}
|
||||
max={10}
|
||||
min={0.1}
|
||||
onChange={(v) => handleUpdate({ leftWidth: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.leftWidth * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Right"
|
||||
value={Math.round(node.rightWidth * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ rightWidth: v })}
|
||||
min={0.1}
|
||||
max={10}
|
||||
min={0.1}
|
||||
onChange={(v) => handleUpdate({ rightWidth: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.rightWidth * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Rotation">
|
||||
<SliderControl
|
||||
label={<>R<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
|
||||
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||
label={
|
||||
<>
|
||||
R<sub className="ml-[1px] text-[11px] opacity-70">rot</sub>
|
||||
</>
|
||||
}
|
||||
max={180}
|
||||
min={-180}
|
||||
onChange={(degrees) => {
|
||||
const radians = (degrees * Math.PI) / 180
|
||||
handleUpdate({ rotation: radians })
|
||||
}}
|
||||
min={-180}
|
||||
max={180}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||
/>
|
||||
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
label="-90°"
|
||||
onClick={() => handleUpdate({ rotation: node.rotation - Math.PI / 2 })}
|
||||
<ActionButton
|
||||
label="-90°"
|
||||
onClick={() => handleUpdate({ rotation: node.rotation - Math.PI / 2 })}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+90°"
|
||||
onClick={() => handleUpdate({ rotation: node.rotation + Math.PI / 2 })}
|
||||
<ActionButton
|
||||
label="+90°"
|
||||
onClick={() => handleUpdate({ rotation: node.rotation + Math.PI / 2 })}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
label={
|
||||
<>
|
||||
X<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[0] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
min={-50}
|
||||
max={50}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
label={
|
||||
<>
|
||||
Y<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[1] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
min={-50}
|
||||
max={50}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
label={
|
||||
<>
|
||||
Z<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[2] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
min={-50}
|
||||
max={50}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
|
||||
@@ -5,11 +5,10 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { Edit, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
export function SlabPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
@@ -20,9 +19,7 @@ export function SlabPanel() {
|
||||
const setEditingHole = useEditor((s) => s.setEditingHole)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId
|
||||
? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined)
|
||||
: undefined
|
||||
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<SlabNode>) => {
|
||||
@@ -50,7 +47,7 @@ export function SlabPanel() {
|
||||
}, [setEditingHole])
|
||||
|
||||
const handleAddHole = useCallback(() => {
|
||||
if (!node || !selectedId) return
|
||||
if (!(node && selectedId)) return
|
||||
|
||||
const polygon = node.polygon
|
||||
let cx = 0
|
||||
@@ -113,23 +110,23 @@ export function SlabPanel() {
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
title={node.name || "Slab"}
|
||||
icon="/icons/floor.png"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Slab'}
|
||||
width={320}
|
||||
>
|
||||
<PanelSection title="Elevation">
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(node.elevation * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ elevation: v })}
|
||||
min={-1}
|
||||
max={1}
|
||||
min={-1}
|
||||
onChange={(v) => handleUpdate({ elevation: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.elevation * 1000) / 1000}
|
||||
/>
|
||||
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-1.5 px-1 pb-1">
|
||||
<ActionButton label="Sunken (-15cm)" onClick={() => handleUpdate({ elevation: -0.15 })} />
|
||||
<ActionButton label="Ground (0m)" onClick={() => handleUpdate({ elevation: 0 })} />
|
||||
@@ -139,7 +136,7 @@ export function SlabPanel() {
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Info">
|
||||
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between px-2 py-1 text-muted-foreground text-sm">
|
||||
<span>Area</span>
|
||||
<span className="font-mono text-white">{area.toFixed(2)} m²</span>
|
||||
</div>
|
||||
@@ -150,18 +147,21 @@ export function SlabPanel() {
|
||||
<div className="flex flex-col gap-1 pb-2">
|
||||
{node.holes.map((hole, index) => {
|
||||
const holeArea = calculateArea(hole)
|
||||
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||
const isEditing =
|
||||
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
||||
isEditing
|
||||
? 'border-primary/50 bg-primary/10'
|
||||
: 'border-transparent hover:bg-accent/30'
|
||||
}`}
|
||||
key={index}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-xs font-medium ${isEditing ? 'text-primary' : 'text-white'}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`font-medium text-xs ${isEditing ? 'text-primary' : 'text-white'}`}
|
||||
>
|
||||
Hole {index + 1} {isEditing && '(Editing)'}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
@@ -170,24 +170,24 @@ export function SlabPanel() {
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isEditing ? (
|
||||
<ActionButton
|
||||
label="Done"
|
||||
onClick={() => setEditingHole(null)}
|
||||
<ActionButton
|
||||
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
label="Done"
|
||||
onClick={() => setEditingHole(null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground"
|
||||
onClick={() => handleEditHole(index)}
|
||||
type="button"
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300"
|
||||
onClick={() => handleDeleteHole(index)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -199,18 +199,16 @@ export function SlabPanel() {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||
No holes
|
||||
</div>
|
||||
<div className="px-2 py-3 text-center text-muted-foreground text-xs">No holes</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="px-1 pt-1 pb-1">
|
||||
<ActionButton
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
label="Add Hole"
|
||||
onClick={handleAddHole}
|
||||
<ActionButton
|
||||
className="w-full"
|
||||
disabled={editingHole?.nodeId === selectedId}
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
label="Add Hole"
|
||||
onClick={handleAddHole}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type AnyNodeId, type WallNode, useScene } from '@pascal-app/core'
|
||||
import { type AnyNode, type AnyNodeId, useScene, type WallNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
export function WallPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
@@ -15,9 +14,7 @@ export function WallPanel() {
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId
|
||||
? (nodes[selectedId as AnyNode['id']] as WallNode | undefined)
|
||||
: undefined
|
||||
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<WallNode>) => {
|
||||
@@ -43,36 +40,36 @@ export function WallPanel() {
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
title={node.name || "Wall"}
|
||||
icon="/icons/wall.png"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Wall'}
|
||||
width={280}
|
||||
>
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(height * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })}
|
||||
min={0.1}
|
||||
max={6}
|
||||
min={0.1}
|
||||
onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(height * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
value={Math.round(thickness * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })}
|
||||
min={0.05}
|
||||
max={1}
|
||||
min={0.05}
|
||||
onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(thickness * 1000) / 1000}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Info">
|
||||
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between px-2 py-1 text-muted-foreground text-sm">
|
||||
<span>Length</span>
|
||||
<span className="font-mono text-white">{length.toFixed(2)} m</span>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type AnyNodeId, WindowNode, emitter, useScene } from '@pascal-app/core'
|
||||
import { type AnyNode, type AnyNodeId, emitter, useScene, WindowNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { usePresetsAdapter } from '../../../contexts/presets-context'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { ToggleControl } from '../controls/toggle-control'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { PresetsPopover } from './presets/presets-popover'
|
||||
import { usePresetsAdapter } from '../../../contexts/presets-context'
|
||||
|
||||
export function WindowPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
@@ -60,7 +59,7 @@ export function WindowPanel() {
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!selectedId || !node) return
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:item-delete')
|
||||
deleteNode(selectedId as AnyNode['id'])
|
||||
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
@@ -68,7 +67,7 @@ export function WindowPanel() {
|
||||
}, [selectedId, node, deleteNode, setSelection])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!node || !node.parentId) return
|
||||
if (!(node && node.parentId)) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useScene.temporal.getState().pause()
|
||||
const duplicate = WindowNode.parse({
|
||||
@@ -112,23 +111,32 @@ export function WindowPanel() {
|
||||
}
|
||||
}, [node])
|
||||
|
||||
const handleSavePreset = useCallback(async (name: string) => {
|
||||
const data = getWindowPresetData()
|
||||
if (!data || !selectedId) return
|
||||
const presetId = await adapter.savePreset('window', name, data)
|
||||
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
|
||||
}, [getWindowPresetData, selectedId, adapter])
|
||||
const handleSavePreset = useCallback(
|
||||
async (name: string) => {
|
||||
const data = getWindowPresetData()
|
||||
if (!(data && selectedId)) return
|
||||
const presetId = await adapter.savePreset('window', name, data)
|
||||
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
|
||||
},
|
||||
[getWindowPresetData, selectedId, adapter],
|
||||
)
|
||||
|
||||
const handleOverwritePreset = useCallback(async (id: string) => {
|
||||
const data = getWindowPresetData()
|
||||
if (!data || !selectedId) return
|
||||
await adapter.overwritePreset('window', id, data)
|
||||
emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
|
||||
}, [getWindowPresetData, selectedId, adapter])
|
||||
const handleOverwritePreset = useCallback(
|
||||
async (id: string) => {
|
||||
const data = getWindowPresetData()
|
||||
if (!(data && selectedId)) return
|
||||
await adapter.overwritePreset('window', id, data)
|
||||
emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
|
||||
},
|
||||
[getWindowPresetData, selectedId, adapter],
|
||||
)
|
||||
|
||||
const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
|
||||
handleUpdate(data as Partial<WindowNode>)
|
||||
}, [handleUpdate])
|
||||
const handleApplyPreset = useCallback(
|
||||
(data: Record<string, unknown>) => {
|
||||
handleUpdate(data as Partial<WindowNode>)
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
if (!node || node.type !== 'window' || selectedIds.length !== 1) return null
|
||||
|
||||
@@ -137,8 +145,8 @@ export function WindowPanel() {
|
||||
|
||||
const colSum = node.columnRatios.reduce((a, b) => a + b, 0)
|
||||
const rowSum = node.rowRatios.reduce((a, b) => a + b, 0)
|
||||
const normCols = node.columnRatios.map(r => r / colSum)
|
||||
const normRows = node.rowRatios.map(r => r / rowSum)
|
||||
const normCols = node.columnRatios.map((r) => r / colSum)
|
||||
const normRows = node.rowRatios.map((r) => r / rowSum)
|
||||
|
||||
const setColumnRatio = (index: number, newVal: number) => {
|
||||
const clamped = Math.max(0.05, Math.min(0.95, newVal))
|
||||
@@ -168,26 +176,26 @@ export function WindowPanel() {
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
title={node.name || "Window"}
|
||||
icon="/icons/window.png"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Window'}
|
||||
width={320}
|
||||
>
|
||||
{/* Presets strip */}
|
||||
<div className="px-3 pt-2.5 pb-1.5 border-b border-border/30">
|
||||
<div className="border-border/30 border-b px-3 pt-2.5 pb-1.5">
|
||||
<PresetsPopover
|
||||
type="window"
|
||||
onApply={handleApplyPreset}
|
||||
onSave={handleSavePreset}
|
||||
onOverwrite={handleOverwritePreset}
|
||||
onFetchPresets={(tab) => adapter.fetchPresets('window', tab)}
|
||||
onRename={(id, name) => adapter.renamePreset(id, name)}
|
||||
onDelete={(id) => adapter.deletePreset(id)}
|
||||
onToggleCommunity={adapter.togglePresetCommunity}
|
||||
isAuthenticated={adapter.isAuthenticated}
|
||||
onApply={handleApplyPreset}
|
||||
onDelete={(id) => adapter.deletePreset(id)}
|
||||
onFetchPresets={(tab) => adapter.fetchPresets('window', tab)}
|
||||
onOverwrite={handleOverwritePreset}
|
||||
onRename={(id, name) => adapter.renamePreset(id, name)}
|
||||
onSave={handleSavePreset}
|
||||
onToggleCommunity={adapter.togglePresetCommunity}
|
||||
tabs={adapter.tabs}
|
||||
type="window"
|
||||
>
|
||||
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[#3e3e3e] transition-colors">
|
||||
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 font-medium text-muted-foreground text-xs transition-colors hover:bg-[#3e3e3e] hover:text-foreground">
|
||||
<BookMarked className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>Presets</span>
|
||||
</button>
|
||||
@@ -196,31 +204,39 @@ export function WindowPanel() {
|
||||
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
||||
min={-10}
|
||||
label={
|
||||
<>
|
||||
X<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
max={10}
|
||||
min={-10}
|
||||
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
|
||||
min={-10}
|
||||
label={
|
||||
<>
|
||||
Y<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
max={10}
|
||||
min={-10}
|
||||
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
/>
|
||||
<div className="pt-2 pb-1 px-1">
|
||||
<ActionButton
|
||||
icon={<FlipHorizontal2 className="h-4 w-4" />}
|
||||
label="Flip Side"
|
||||
onClick={handleFlip}
|
||||
<div className="px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
className="w-full"
|
||||
icon={<FlipHorizontal2 className="h-4 w-4" />}
|
||||
label="Flip Side"
|
||||
onClick={handleFlip}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
@@ -228,101 +244,103 @@ export function WindowPanel() {
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Width"
|
||||
value={Math.round(node.width * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ width: v })}
|
||||
min={0.2}
|
||||
max={5}
|
||||
min={0.2}
|
||||
onChange={(v) => handleUpdate({ width: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.width * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ height: v })}
|
||||
min={0.2}
|
||||
max={5}
|
||||
min={0.2}
|
||||
onChange={(v) => handleUpdate({ height: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Frame">
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
value={Math.round(node.frameThickness * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ frameThickness: v })}
|
||||
min={0.01}
|
||||
max={0.2}
|
||||
min={0.01}
|
||||
onChange={(v) => handleUpdate({ frameThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.frameThickness * 1000) / 1000}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
value={Math.round(node.frameDepth * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||
min={0.01}
|
||||
max={0.3}
|
||||
min={0.01}
|
||||
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.frameDepth * 1000) / 1000}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Grid">
|
||||
<SliderControl
|
||||
label="Columns"
|
||||
value={numCols}
|
||||
max={8}
|
||||
min={1}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
min={1}
|
||||
max={8}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={numCols}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Rows"
|
||||
value={numRows}
|
||||
max={8}
|
||||
min={1}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
min={1}
|
||||
max={8}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={numRows}
|
||||
/>
|
||||
|
||||
{numCols > 1 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<div className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Col Widths</div>
|
||||
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Col Widths
|
||||
</div>
|
||||
{normCols.map((ratio, i) => (
|
||||
<SliderControl
|
||||
key={`c-${i}`}
|
||||
label={`C${i + 1}`}
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
onChange={(v) => setColumnRatio(i, v / 100)}
|
||||
min={5}
|
||||
max={95}
|
||||
min={5}
|
||||
onChange={(v) => setColumnRatio(i, v / 100)}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1 border-t border-border/50 pt-1">
|
||||
<div className="mt-1 border-border/50 border-t pt-1">
|
||||
<SliderControl
|
||||
label="Divider"
|
||||
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
|
||||
min={0.005}
|
||||
max={0.1}
|
||||
min={0.005}
|
||||
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,30 +348,32 @@ export function WindowPanel() {
|
||||
|
||||
{numRows > 1 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<div className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Row Heights</div>
|
||||
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Row Heights
|
||||
</div>
|
||||
{normRows.map((ratio, i) => (
|
||||
<SliderControl
|
||||
key={`r-${i}`}
|
||||
label={`R${i + 1}`}
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
onChange={(v) => setRowRatio(i, v / 100)}
|
||||
min={5}
|
||||
max={95}
|
||||
min={5}
|
||||
onChange={(v) => setRowRatio(i, v / 100)}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1 border-t border-border/50 pt-1">
|
||||
<div className="mt-1 border-border/50 border-t pt-1">
|
||||
<SliderControl
|
||||
label="Divider"
|
||||
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
|
||||
min={0.005}
|
||||
max={0.1}
|
||||
min={0.005}
|
||||
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,31 +382,31 @@ export function WindowPanel() {
|
||||
|
||||
<PanelSection title="Sill">
|
||||
<ToggleControl
|
||||
label="Enable Sill"
|
||||
checked={node.sill}
|
||||
label="Enable Sill"
|
||||
onChange={(checked) => handleUpdate({ sill: checked })}
|
||||
/>
|
||||
{node.sill && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
value={Math.round(node.sillDepth * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ sillDepth: v })}
|
||||
min={0.01}
|
||||
max={0.5}
|
||||
min={0.01}
|
||||
onChange={(v) => handleUpdate({ sillDepth: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.sillDepth * 1000) / 1000}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
value={Math.round(node.sillThickness * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ sillThickness: v })}
|
||||
min={0.005}
|
||||
max={0.2}
|
||||
min={0.005}
|
||||
onChange={(v) => handleUpdate({ sillThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.sillThickness * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -395,12 +415,16 @@ export function WindowPanel() {
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
|
||||
<ActionButton
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
<ActionButton
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<ActionButton
|
||||
className="hover:bg-red-500/20"
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover'
|
||||
|
||||
export const PALETTE_COLORS = [
|
||||
'#ef4444', // Red 0°
|
||||
@@ -28,27 +28,30 @@ export function ColorDot({ color, onChange }: ColorDotProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="relative shrink-0 h-3 w-3 rounded-sm border border-border/50 cursor-pointer hover:ring-1 hover:ring-ring/50 transition-all"
|
||||
style={{ backgroundColor: color }}
|
||||
className="relative h-3 w-3 shrink-0 cursor-pointer rounded-sm border border-border/50 transition-all hover:ring-1 hover:ring-ring/50"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ backgroundColor: color }}
|
||||
type="button"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="left" align="center" sideOffset={6} className="w-auto p-1.5">
|
||||
<PopoverContent align="center" className="w-auto p-1.5" side="left" sideOffset={6}>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{PALETTE_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={cn(
|
||||
'h-5 w-5 rounded-sm border transition-transform hover:scale-110',
|
||||
c === color ? 'border-foreground/50 ring-1 ring-ring/50' : 'border-border/30',
|
||||
)}
|
||||
key={c}
|
||||
onClick={() => {
|
||||
onChange(c)
|
||||
setOpen(false)
|
||||
}}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => { onChange(c); setOpen(false) }}
|
||||
type="button"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ function ContextMenuSubTrigger({
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm 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",
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 font-barlow 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",
|
||||
className,
|
||||
)}
|
||||
data-inset={inset}
|
||||
@@ -104,7 +104,7 @@ function ContextMenuItem({
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
className={cn(
|
||||
"data-[variant=destructive]:*:[svg]:!text-destructive relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm 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 relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 font-barlow 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",
|
||||
className,
|
||||
)}
|
||||
data-inset={inset}
|
||||
@@ -125,7 +125,7 @@ function ContextMenuCheckboxItem({
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
checked={checked}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm 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",
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 font-barlow 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",
|
||||
className,
|
||||
)}
|
||||
data-slot="context-menu-checkbox-item"
|
||||
@@ -149,7 +149,7 @@ function ContextMenuRadioItem({
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm 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",
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 font-barlow 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",
|
||||
className,
|
||||
)}
|
||||
data-slot="context-menu-radio-item"
|
||||
@@ -174,7 +174,10 @@ function ContextMenuLabel({
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
className={cn('px-2 py-1.5 font-medium font-barlow text-foreground text-sm data-[inset]:pl-8', className)}
|
||||
className={cn(
|
||||
'px-2 py-1.5 font-barlow font-medium text-foreground text-sm data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
data-inset={inset}
|
||||
data-slot="context-menu-label"
|
||||
{...props}
|
||||
|
||||
@@ -58,7 +58,7 @@ function DropdownMenuItem({
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm 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!",
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 font-barlow 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!",
|
||||
className,
|
||||
)}
|
||||
data-inset={inset}
|
||||
@@ -79,7 +79,7 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
checked={checked}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm 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",
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 font-barlow 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",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
@@ -109,7 +109,7 @@ function DropdownMenuRadioItem({
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm 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",
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 font-barlow 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",
|
||||
className,
|
||||
)}
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
@@ -134,7 +134,7 @@ function DropdownMenuLabel({
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
className={cn('px-2 py-1.5 font-medium font-barlow text-sm data-inset:pl-8', className)}
|
||||
className={cn('px-2 py-1.5 font-barlow font-medium text-sm data-inset:pl-8', className)}
|
||||
data-inset={inset}
|
||||
data-slot="dropdown-menu-label"
|
||||
{...props}
|
||||
@@ -180,7 +180,7 @@ function DropdownMenuSubTrigger({
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm 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",
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 font-barlow 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",
|
||||
className,
|
||||
)}
|
||||
data-inset={inset}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react'
|
||||
import React, { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode
|
||||
@@ -33,8 +33,8 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center bg-[#1b1c1f] p-4 text-white">
|
||||
<h2 className="mb-4 text-xl font-bold text-red-400">Something went wrong</h2>
|
||||
<pre className="max-w-full overflow-auto rounded bg-black/30 p-4 text-sm text-gray-300">
|
||||
<h2 className="mb-4 font-bold text-red-400 text-xl">Something went wrong</h2>
|
||||
<pre className="max-w-full overflow-auto rounded bg-black/30 p-4 text-gray-300 text-sm">
|
||||
{this.state.error?.message}
|
||||
</pre>
|
||||
<button
|
||||
@@ -50,4 +50,3 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import NumberFlow from '@number-flow/react'
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import NumberFlow from '@number-flow/react'
|
||||
|
||||
interface NumberInputProps {
|
||||
label: string
|
||||
@@ -132,10 +132,10 @@ export function NumberInput({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`${className} relative group/input`}>
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 bg-primary/10 dark:bg-primary/20 pointer-events-none transition-all duration-75 ${isDragging ? 'opacity-100' : 'opacity-0'}`}
|
||||
style={{
|
||||
<div className={`${className} group/input relative`}>
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-y-0 left-0 bg-primary/10 transition-all duration-75 dark:bg-primary/20 ${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',
|
||||
@@ -143,35 +143,41 @@ export function NumberInput({
|
||||
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
|
||||
className={`relative z-10 flex items-center overflow-hidden rounded-lg border shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] transition-all focus-within:border-primary focus-within:ring-1 focus-within:ring-primary ${isDragging ? 'border-neutral-300 bg-transparent ring-1 ring-neutral-200/60 dark:border-border dark:ring-border/50' : 'border-neutral-200/60 bg-white hover:border-neutral-300 dark:border-border/50 dark:bg-accent/30 dark:hover:border-border/80'}`}
|
||||
>
|
||||
<div
|
||||
ref={labelRef}
|
||||
className={`pl-2 pr-1 py-1.5 text-muted-foreground text-xs select-none font-barlow font-medium truncate z-10 ${
|
||||
isDragging ? 'cursor-ew-resize text-foreground' : 'hover:cursor-ew-resize hover:text-foreground'
|
||||
className={`z-10 select-none truncate py-1.5 pr-1 pl-2 font-barlow font-medium text-muted-foreground text-xs ${
|
||||
isDragging
|
||||
? 'cursor-ew-resize text-foreground'
|
||||
: 'hover:cursor-ew-resize hover:text-foreground'
|
||||
} transition-colors`}
|
||||
onMouseDown={handleLabelMouseDown}
|
||||
ref={labelRef}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<input
|
||||
autoFocus
|
||||
size={1}
|
||||
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"
|
||||
className="z-10 min-w-0 flex-1 bg-transparent px-2 py-1.5 text-right font-medium font-mono text-foreground text-sm outline-none placeholder:text-muted-foreground/50"
|
||||
onBlur={handleInputBlur}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
size={1}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
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`}
|
||||
className={
|
||||
'z-10 min-w-0 flex-1 cursor-text truncate px-2 py-1.5 text-right font-medium font-mono text-foreground text-sm tabular-nums tracking-tight transition-colors hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}
|
||||
onClick={handleValueClick}
|
||||
>
|
||||
<NumberFlow
|
||||
value={Number(value.toFixed(precision))}
|
||||
format={{ minimumFractionDigits: precision, maximumFractionDigits: precision }}
|
||||
value={Number(value.toFixed(precision))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
'use client'
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from "./../../../lib/utils"
|
||||
import { cn } from './../../../lib/utils'
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
@@ -11,15 +11,15 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
@@ -27,4 +27,3 @@ const Switch = React.forwardRef<
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ function TooltipContent({
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
className={cn(
|
||||
'fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in text-balance rounded-md bg-foreground px-3 py-1.5 text-background font-barlow 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 font-barlow text-background text-xs data-[state=closed]:animate-out',
|
||||
className,
|
||||
)}
|
||||
data-slot="tooltip-content"
|
||||
|
||||
@@ -27,14 +27,14 @@ export function SceneLoader({ className, fullScreen = false }: SceneLoaderProps)
|
||||
if (!loaderClass) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className={cn(
|
||||
"z-100 flex items-center justify-center bg-background/80 backdrop-blur-md transition-opacity duration-300",
|
||||
fullScreen ? "fixed inset-0" : "absolute inset-0",
|
||||
className
|
||||
'z-100 flex items-center justify-center bg-background/80 backdrop-blur-md transition-opacity duration-300',
|
||||
fullScreen ? 'fixed inset-0' : 'absolute inset-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn(loaderClass, "text-foreground opacity-80")} />
|
||||
<div className={cn(loaderClass, 'text-foreground opacity-80')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,67 +1,72 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { CommandPalette } from "./../../../components/ui/command-palette";
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { CommandPalette } from './../../../components/ui/command-palette'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarHeader,
|
||||
useSidebarStore,
|
||||
} from "./../../../components/ui/primitives/sidebar";
|
||||
import { cn } from "./../../../lib/utils";
|
||||
import { IconRail, type PanelId } from "./icon-rail";
|
||||
import { SettingsPanel, type SettingsPanelProps } from "./panels/settings-panel";
|
||||
import { SitePanel, type SitePanelProps } from "./panels/site-panel";
|
||||
} from './../../../components/ui/primitives/sidebar'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import { IconRail, type PanelId } from './icon-rail'
|
||||
import { SettingsPanel, type SettingsPanelProps } from './panels/settings-panel'
|
||||
import { SitePanel, type SitePanelProps } from './panels/site-panel'
|
||||
|
||||
interface AppSidebarProps {
|
||||
appMenuButton?: ReactNode;
|
||||
sidebarTop?: ReactNode;
|
||||
settingsPanelProps?: SettingsPanelProps;
|
||||
sitePanelProps?: SitePanelProps;
|
||||
appMenuButton?: ReactNode
|
||||
sidebarTop?: ReactNode
|
||||
settingsPanelProps?: SettingsPanelProps
|
||||
sitePanelProps?: SitePanelProps
|
||||
}
|
||||
|
||||
export function AppSidebar({ appMenuButton, sidebarTop, settingsPanelProps, sitePanelProps }: AppSidebarProps) {
|
||||
const [activePanel, setActivePanel] = useState<PanelId>("site");
|
||||
export function AppSidebar({
|
||||
appMenuButton,
|
||||
sidebarTop,
|
||||
settingsPanelProps,
|
||||
sitePanelProps,
|
||||
}: AppSidebarProps) {
|
||||
const [activePanel, setActivePanel] = useState<PanelId>('site')
|
||||
|
||||
useEffect(() => {
|
||||
// Widen default sidebar (288px → 432px) for better project title visibility
|
||||
const store = useSidebarStore.getState();
|
||||
const store = useSidebarStore.getState()
|
||||
if (store.width <= 288) {
|
||||
store.setWidth(432);
|
||||
store.setWidth(432)
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
const renderPanelContent = () => {
|
||||
switch (activePanel) {
|
||||
case "site":
|
||||
return <SitePanel {...sitePanelProps} />;
|
||||
case "settings":
|
||||
return <SettingsPanel {...settingsPanelProps} />;
|
||||
case 'site':
|
||||
return <SitePanel {...sitePanelProps} />
|
||||
case 'settings':
|
||||
return <SettingsPanel {...settingsPanelProps} />
|
||||
default:
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar className={cn("dark text-white")} variant="floating">
|
||||
<Sidebar className={cn('dark text-white')} variant="floating">
|
||||
<div className="flex h-full">
|
||||
{/* Icon Rail */}
|
||||
<IconRail
|
||||
activePanel={activePanel}
|
||||
onPanelChange={setActivePanel}
|
||||
appMenuButton={appMenuButton}
|
||||
onPanelChange={setActivePanel}
|
||||
/>
|
||||
|
||||
{/* Panel Content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{sidebarTop && (
|
||||
<SidebarHeader className="flex-col items-start justify-center px-3 py-3 gap-1 border-b border-border/50 relative">
|
||||
<SidebarHeader className="relative flex-col items-start justify-center gap-1 border-border/50 border-b px-3 py-3">
|
||||
{sidebarTop}
|
||||
</SidebarHeader>
|
||||
)}
|
||||
|
||||
<SidebarContent className={cn("no-scrollbar flex flex-1 flex-col overflow-hidden")}>
|
||||
<SidebarContent className={cn('no-scrollbar flex flex-1 flex-col overflow-hidden')}>
|
||||
{renderPanelContent()}
|
||||
</SidebarContent>
|
||||
</div>
|
||||
@@ -69,5 +74,5 @@ export function AppSidebar({ appMenuButton, sidebarTop, settingsPanelProps, site
|
||||
</Sidebar>
|
||||
<CommandPalette />
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,48 +1,43 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "./../../../components/ui/primitives/tooltip";
|
||||
import { cn } from "./../../../lib/utils";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
} from './../../../components/ui/primitives/tooltip'
|
||||
import { cn } from './../../../lib/utils'
|
||||
|
||||
export type PanelId = "site" | "settings";
|
||||
export type PanelId = 'site' | 'settings'
|
||||
|
||||
interface IconRailProps {
|
||||
activePanel: PanelId;
|
||||
onPanelChange: (panel: PanelId) => void;
|
||||
appMenuButton?: ReactNode;
|
||||
className?: string;
|
||||
activePanel: PanelId
|
||||
onPanelChange: (panel: PanelId) => void
|
||||
appMenuButton?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const panels: { id: PanelId; iconSrc: string; label: string }[] = [
|
||||
{ id: "site", iconSrc: "/icons/level.png", label: "Site" },
|
||||
{ id: "settings", iconSrc: "/icons/settings.png", label: "Settings" },
|
||||
];
|
||||
{ id: 'site', iconSrc: '/icons/level.png', label: 'Site' },
|
||||
{ id: 'settings', iconSrc: '/icons/settings.png', label: 'Settings' },
|
||||
]
|
||||
|
||||
export function IconRail({
|
||||
activePanel,
|
||||
onPanelChange,
|
||||
appMenuButton,
|
||||
className,
|
||||
}: IconRailProps) {
|
||||
const theme = useViewer((state) => state.theme);
|
||||
const setTheme = useViewer((state) => state.setTheme);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
export function IconRail({ activePanel, onPanelChange, appMenuButton, className }: IconRailProps) {
|
||||
const theme = useViewer((state) => state.theme)
|
||||
const setTheme = useViewer((state) => state.setTheme)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-11 flex-col items-center gap-1 border-border/50 border-r py-2",
|
||||
'flex h-full w-11 flex-col items-center gap-1 border-border/50 border-r py-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -50,34 +45,34 @@ export function IconRail({
|
||||
{appMenuButton}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-8 h-px bg-border/50 mb-1" />
|
||||
<div className="mb-1 h-px w-8 bg-border/50" />
|
||||
|
||||
{panels.map((panel) => {
|
||||
const isActive = activePanel === panel.id;
|
||||
const isActive = activePanel === panel.id
|
||||
return (
|
||||
<Tooltip key={panel.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-lg transition-all",
|
||||
isActive ? "bg-accent" : "hover:bg-accent",
|
||||
'flex h-9 w-9 items-center justify-center rounded-lg transition-all',
|
||||
isActive ? 'bg-accent' : 'hover:bg-accent',
|
||||
)}
|
||||
onClick={() => onPanelChange(panel.id)}
|
||||
type="button"
|
||||
>
|
||||
<img
|
||||
src={panel.iconSrc}
|
||||
alt={panel.label}
|
||||
className={cn(
|
||||
"h-6 w-6 transition-all object-contain",
|
||||
!isActive && "opacity-50 saturate-0"
|
||||
'h-6 w-6 object-contain transition-all',
|
||||
!isActive && 'opacity-50 saturate-0',
|
||||
)}
|
||||
src={panel.iconSrc}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{panel.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Spacer */}
|
||||
@@ -88,17 +83,17 @@ export function IconRail({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-accent/40 transition-all text-foreground hover:bg-accent mb-2"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-accent/40 text-foreground transition-all hover:bg-accent"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
type="button"
|
||||
>
|
||||
<motion.div
|
||||
key={theme}
|
||||
initial={{ rotate: -90, opacity: 0 }}
|
||||
animate={{ rotate: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
initial={{ rotate: -90, opacity: 0 }}
|
||||
key={theme}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</motion.div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -106,7 +101,7 @@ export function IconRail({
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export { panels };
|
||||
export { panels }
|
||||
|
||||
+40
-29
@@ -1,19 +1,32 @@
|
||||
import { Volume2, VolumeX } from 'lucide-react'
|
||||
import { Button } from '../../../../../components/ui/primitives/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '../../../../../components/ui/primitives/dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../../../../../components/ui/primitives/dialog'
|
||||
import { Slider } from '../../../../../components/ui/slider'
|
||||
import useAudio from '../../../../../store/use-audio'
|
||||
|
||||
export function AudioSettingsDialog() {
|
||||
const { masterVolume, sfxVolume, radioVolume, muted, setMasterVolume, setSfxVolume, setRadioVolume, toggleMute } = useAudio()
|
||||
const {
|
||||
masterVolume,
|
||||
sfxVolume,
|
||||
radioVolume,
|
||||
muted,
|
||||
setMasterVolume,
|
||||
setSfxVolume,
|
||||
setRadioVolume,
|
||||
toggleMute,
|
||||
} = useAudio()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
variant="outline"
|
||||
>
|
||||
<Button className="w-full justify-start gap-2" variant="outline">
|
||||
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
|
||||
Audio Settings
|
||||
</Button>
|
||||
@@ -21,62 +34,60 @@ export function AudioSettingsDialog() {
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Audio Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Adjust volume levels and mute settings
|
||||
</DialogDescription>
|
||||
<DialogDescription>Adjust volume levels and mute settings</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Master Volume */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Master Volume</label>
|
||||
<span className="text-sm text-muted-foreground">{masterVolume}%</span>
|
||||
<label className="font-medium text-sm">Master Volume</label>
|
||||
<span className="text-muted-foreground text-sm">{masterVolume}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[masterVolume]}
|
||||
onValueChange={(value) => value[0] !== undefined && setMasterVolume(value[0])}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={muted}
|
||||
max={100}
|
||||
onValueChange={(value) => value[0] !== undefined && setMasterVolume(value[0])}
|
||||
step={1}
|
||||
value={[masterVolume]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Radio Volume */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Radio Volume</label>
|
||||
<span className="text-sm text-muted-foreground">{radioVolume}%</span>
|
||||
<label className="font-medium text-sm">Radio Volume</label>
|
||||
<span className="text-muted-foreground text-sm">{radioVolume}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[radioVolume]}
|
||||
onValueChange={(value) => value[0] !== undefined && setRadioVolume(value[0])}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={muted}
|
||||
max={100}
|
||||
onValueChange={(value) => value[0] !== undefined && setRadioVolume(value[0])}
|
||||
step={1}
|
||||
value={[radioVolume]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SFX Volume */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Sound Effects</label>
|
||||
<span className="text-sm text-muted-foreground">{sfxVolume}%</span>
|
||||
<label className="font-medium text-sm">Sound Effects</label>
|
||||
<span className="text-muted-foreground text-sm">{sfxVolume}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[sfxVolume]}
|
||||
onValueChange={(value) => value[0] !== undefined && setSfxVolume(value[0])}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={muted}
|
||||
max={100}
|
||||
onValueChange={(value) => value[0] !== undefined && setSfxVolume(value[0])}
|
||||
step={1}
|
||||
value={[sfxVolume]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mute Toggle */}
|
||||
<div className="pt-4 border-t">
|
||||
<div className="border-t pt-4">
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={toggleMute}
|
||||
variant={muted ? 'default' : 'outline'}
|
||||
className="w-full justify-start gap-2"
|
||||
>
|
||||
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
|
||||
{muted ? 'Unmute All Sounds' : 'Mute All Sounds'}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { emitter, useScene } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { VisualJson, TreeView } from "@visual-json/react";
|
||||
import { Camera, Download, Save, Trash2, Upload } from "lucide-react";
|
||||
import { emitter, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { TreeView, VisualJson } from '@visual-json/react'
|
||||
import { Camera, Download, Save, Trash2, Upload } from 'lucide-react'
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
type SyntheticEvent,
|
||||
@@ -9,113 +9,113 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from "./../../../../../components/ui/primitives/button";
|
||||
} from 'react'
|
||||
import { Button } from './../../../../../components/ui/primitives/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "./../../../../../components/ui/primitives/dialog";
|
||||
import { Switch } from "./../../../../../components/ui/primitives/switch";
|
||||
import useEditor from "./../../../../../store/use-editor";
|
||||
import { AudioSettingsDialog } from "./audio-settings-dialog";
|
||||
import { KeyboardShortcutsDialog } from "./keyboard-shortcuts-dialog";
|
||||
} from './../../../../../components/ui/primitives/dialog'
|
||||
import { Switch } from './../../../../../components/ui/primitives/switch'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { AudioSettingsDialog } from './audio-settings-dialog'
|
||||
import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
|
||||
|
||||
type SceneNode = Record<string, unknown> & {
|
||||
id?: unknown;
|
||||
type?: unknown;
|
||||
name?: unknown;
|
||||
parentId?: unknown;
|
||||
children?: unknown;
|
||||
};
|
||||
id?: unknown
|
||||
type?: unknown
|
||||
name?: unknown
|
||||
parentId?: unknown
|
||||
children?: unknown
|
||||
}
|
||||
|
||||
type SceneGraphNode = {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string | null;
|
||||
parentId: string | null;
|
||||
children: SceneGraphNode[];
|
||||
missing?: true;
|
||||
cycle?: true;
|
||||
};
|
||||
id: string
|
||||
type: string
|
||||
name: string | null
|
||||
parentId: string | null
|
||||
children: SceneGraphNode[]
|
||||
missing?: true
|
||||
cycle?: true
|
||||
}
|
||||
|
||||
type SceneGraphValue = {
|
||||
roots: SceneGraphNode[];
|
||||
detachedNodes?: SceneGraphNode[];
|
||||
};
|
||||
roots: SceneGraphNode[]
|
||||
detachedNodes?: SceneGraphNode[]
|
||||
}
|
||||
|
||||
const isSceneNode = (value: unknown): value is SceneNode => {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
"id" in value &&
|
||||
typeof (value as { id: unknown }).id === "string"
|
||||
);
|
||||
};
|
||||
'id' in value &&
|
||||
typeof (value as { id: unknown }).id === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
const getChildIdsFromNode = (node: SceneNode): string[] => {
|
||||
if (!Array.isArray(node.children)) {
|
||||
return [];
|
||||
return []
|
||||
}
|
||||
|
||||
const childIds = new Set<string>();
|
||||
const childIds = new Set<string>()
|
||||
|
||||
for (const child of node.children) {
|
||||
if (typeof child === "string") {
|
||||
childIds.add(child);
|
||||
continue;
|
||||
if (typeof child === 'string') {
|
||||
childIds.add(child)
|
||||
continue
|
||||
}
|
||||
|
||||
if (isSceneNode(child)) {
|
||||
childIds.add(child.id as string);
|
||||
childIds.add(child.id as string)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(childIds);
|
||||
};
|
||||
return Array.from(childIds)
|
||||
}
|
||||
|
||||
const buildSceneGraphValue = (
|
||||
nodes: Record<string, SceneNode>,
|
||||
rootNodeIds: string[],
|
||||
): SceneGraphValue => {
|
||||
const childIdsByParent = new Map<string, Set<string>>();
|
||||
const childIdsByParent = new Map<string, Set<string>>()
|
||||
|
||||
for (const [id, node] of Object.entries(nodes)) {
|
||||
const childIds = getChildIdsFromNode(node);
|
||||
const childIds = getChildIdsFromNode(node)
|
||||
if (childIds.length > 0) {
|
||||
childIdsByParent.set(id, new Set(childIds));
|
||||
childIdsByParent.set(id, new Set(childIds))
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, node] of Object.entries(nodes)) {
|
||||
if (typeof node.parentId !== "string") {
|
||||
continue;
|
||||
if (typeof node.parentId !== 'string') {
|
||||
continue
|
||||
}
|
||||
|
||||
const siblings = childIdsByParent.get(node.parentId) ?? new Set<string>();
|
||||
siblings.add(id);
|
||||
childIdsByParent.set(node.parentId, siblings);
|
||||
const siblings = childIdsByParent.get(node.parentId) ?? new Set<string>()
|
||||
siblings.add(id)
|
||||
childIdsByParent.set(node.parentId, siblings)
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const visited = new Set<string>()
|
||||
|
||||
const buildNode = (id: string, path: Set<string>): SceneGraphNode => {
|
||||
const node = nodes[id];
|
||||
const node = nodes[id]
|
||||
if (!node) {
|
||||
return {
|
||||
id,
|
||||
type: "missing",
|
||||
type: 'missing',
|
||||
name: null,
|
||||
parentId: null,
|
||||
missing: true,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const nodeType = typeof node.type === "string" ? node.type : "unknown";
|
||||
const nodeName = typeof node.name === "string" ? node.name : null;
|
||||
const parentId = typeof node.parentId === "string" ? node.parentId : null;
|
||||
const nodeType = typeof node.type === 'string' ? node.type : 'unknown'
|
||||
const nodeName = typeof node.name === 'string' ? node.name : null
|
||||
const parentId = typeof node.parentId === 'string' ? node.parentId : null
|
||||
|
||||
if (path.has(id)) {
|
||||
return {
|
||||
@@ -125,35 +125,35 @@ const buildSceneGraphValue = (
|
||||
parentId,
|
||||
cycle: true,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
visited.add(id);
|
||||
const nextPath = new Set(path);
|
||||
nextPath.add(id);
|
||||
visited.add(id)
|
||||
const nextPath = new Set(path)
|
||||
nextPath.add(id)
|
||||
|
||||
const childIds = Array.from(childIdsByParent.get(id) ?? []);
|
||||
const childIds = Array.from(childIdsByParent.get(id) ?? [])
|
||||
return {
|
||||
id,
|
||||
type: nodeType,
|
||||
name: nodeName,
|
||||
parentId,
|
||||
children: childIds.map((childId) => buildNode(childId, nextPath)),
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const roots = rootNodeIds.map((id) => buildNode(id, new Set()));
|
||||
const detachedNodeIds = Object.keys(nodes).filter((id) => !visited.has(id));
|
||||
const roots = rootNodeIds.map((id) => buildNode(id, new Set()))
|
||||
const detachedNodeIds = Object.keys(nodes).filter((id) => !visited.has(id))
|
||||
|
||||
if (detachedNodeIds.length === 0) {
|
||||
return { roots };
|
||||
return { roots }
|
||||
}
|
||||
|
||||
return {
|
||||
roots,
|
||||
detachedNodes: detachedNodeIds.map((id) => buildNode(id, new Set())),
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProjectVisibility {
|
||||
isPrivate: boolean
|
||||
@@ -164,113 +164,115 @@ export interface ProjectVisibility {
|
||||
export interface SettingsPanelProps {
|
||||
projectId?: string
|
||||
projectVisibility?: ProjectVisibility
|
||||
onVisibilityChange?: (field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic', value: boolean) => Promise<void>
|
||||
onVisibilityChange?: (
|
||||
field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic',
|
||||
value: boolean,
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
export function SettingsPanel({ projectId, projectVisibility, onVisibilityChange }: SettingsPanelProps = {}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const nodes = useScene((state) => state.nodes);
|
||||
const rootNodeIds = useScene((state) => state.rootNodeIds);
|
||||
const setScene = useScene((state) => state.setScene);
|
||||
const clearScene = useScene((state) => state.clearScene);
|
||||
const resetSelection = useViewer((state) => state.resetSelection);
|
||||
const exportScene = useViewer((state) => state.exportScene);
|
||||
const setPhase = useEditor((state) => state.setPhase);
|
||||
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false);
|
||||
export function SettingsPanel({
|
||||
projectId,
|
||||
projectVisibility,
|
||||
onVisibilityChange,
|
||||
}: SettingsPanelProps = {}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
const rootNodeIds = useScene((state) => state.rootNodeIds)
|
||||
const setScene = useScene((state) => state.setScene)
|
||||
const clearScene = useScene((state) => state.clearScene)
|
||||
const resetSelection = useViewer((state) => state.resetSelection)
|
||||
const exportScene = useViewer((state) => state.exportScene)
|
||||
const setPhase = useEditor((state) => state.setPhase)
|
||||
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
|
||||
const sceneGraphValue = useMemo(
|
||||
() => buildSceneGraphValue(nodes as Record<string, SceneNode>, rootNodeIds),
|
||||
[nodes, rootNodeIds],
|
||||
);
|
||||
)
|
||||
const blockSceneGraphMutations = useCallback((event: SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
const blockSceneGraphDeletion = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Delete" || event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}, [])
|
||||
const blockSceneGraphDeletion = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isLocalProject = false; // Props-based; only show cloud sections when projectId provided
|
||||
const isLocalProject = false // Props-based; only show cloud sections when projectId provided
|
||||
|
||||
const handleExport = async () => {
|
||||
if (exportScene) {
|
||||
await exportScene();
|
||||
await exportScene()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleSaveBuild = () => {
|
||||
const sceneData = { nodes, rootNodeIds };
|
||||
const json = JSON.stringify(sceneData, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
link.download = `layout_${date}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
const sceneData = { nodes, rootNodeIds }
|
||||
const json = JSON.stringify(sceneData, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
const date = new Date().toISOString().split('T')[0]
|
||||
link.download = `layout_${date}.json`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleFileLoad = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const reader = new FileReader();
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.target?.result as string);
|
||||
const data = JSON.parse(event.target?.result as string)
|
||||
if (data.nodes && data.rootNodeIds) {
|
||||
setScene(data.nodes, data.rootNodeIds);
|
||||
resetSelection();
|
||||
setPhase("site");
|
||||
setScene(data.nodes, data.rootNodeIds)
|
||||
resetSelection()
|
||||
setPhase('site')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load build:", err);
|
||||
console.error('Failed to load build:', err)
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
reader.readAsText(file)
|
||||
|
||||
// Reset input so the same file can be loaded again
|
||||
e.target.value = "";
|
||||
};
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleResetToDefault = () => {
|
||||
clearScene();
|
||||
resetSelection();
|
||||
setPhase("site");
|
||||
};
|
||||
clearScene()
|
||||
resetSelection()
|
||||
setPhase('site')
|
||||
}
|
||||
|
||||
const handleGenerateThumbnail = () => {
|
||||
if (!projectId) return;
|
||||
setIsGeneratingThumbnail(true);
|
||||
emitter.emit('camera-controls:generate-thumbnail', { projectId });
|
||||
setTimeout(() => setIsGeneratingThumbnail(false), 3000);
|
||||
};
|
||||
if (!projectId) return
|
||||
setIsGeneratingThumbnail(true)
|
||||
emitter.emit('camera-controls:generate-thumbnail', { projectId })
|
||||
setTimeout(() => setIsGeneratingThumbnail(false), 3000)
|
||||
}
|
||||
|
||||
const handleVisibilityChange = async (
|
||||
field: 'isPrivate' | 'showScansPublic' | 'showGuidesPublic',
|
||||
value: boolean,
|
||||
) => {
|
||||
await onVisibilityChange?.(field, value);
|
||||
};
|
||||
await onVisibilityChange?.(field, value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-3">
|
||||
{/* Visibility Section (only for cloud projects) */}
|
||||
{projectId && !isLocalProject && (
|
||||
<div className="space-y-3">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">
|
||||
Visibility
|
||||
</label>
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Visibility</label>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Public</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="font-medium text-sm">Public</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{projectVisibility?.isPrivate ? 'Only you' : 'Anyone'} can view
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,10 +283,8 @@ export function SettingsPanel({ projectId, projectVisibility, onVisibilityChange
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Show 3D Scans</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Visible to public viewers
|
||||
</div>
|
||||
<div className="font-medium text-sm">Show 3D Scans</div>
|
||||
<div className="text-muted-foreground text-xs">Visible to public viewers</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={projectVisibility?.showScansPublic ?? true}
|
||||
@@ -293,10 +293,8 @@ export function SettingsPanel({ projectId, projectVisibility, onVisibilityChange
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Show Floorplans</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Visible to public viewers
|
||||
</div>
|
||||
<div className="font-medium text-sm">Show Floorplans</div>
|
||||
<div className="text-muted-foreground text-xs">Visible to public viewers</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={projectVisibility?.showGuidesPublic ?? true}
|
||||
@@ -305,10 +303,8 @@ export function SettingsPanel({ projectId, projectVisibility, onVisibilityChange
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Show Grid</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Visible only in the editor
|
||||
</div>
|
||||
<div className="font-medium text-sm">Show Grid</div>
|
||||
<div className="text-muted-foreground text-xs">Visible only in the editor</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={useViewer((state) => state.showGrid)}
|
||||
@@ -320,14 +316,8 @@ export function SettingsPanel({ projectId, projectVisibility, onVisibilityChange
|
||||
|
||||
{/* Export Section */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">
|
||||
Export
|
||||
</label>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleExport}
|
||||
variant="outline"
|
||||
>
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Export</label>
|
||||
<Button className="w-full justify-start gap-2" onClick={handleExport} variant="outline">
|
||||
<Download className="size-4" />
|
||||
Export 3D Model
|
||||
</Button>
|
||||
@@ -336,14 +326,12 @@ export function SettingsPanel({ projectId, projectVisibility, onVisibilityChange
|
||||
{/* Thumbnail Section (only for cloud projects) */}
|
||||
{projectId && !isLocalProject && (
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">
|
||||
Thumbnail
|
||||
</label>
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Thumbnail</label>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
disabled={isGeneratingThumbnail}
|
||||
onClick={handleGenerateThumbnail}
|
||||
variant="outline"
|
||||
disabled={isGeneratingThumbnail}
|
||||
>
|
||||
<Camera className="size-4" />
|
||||
{isGeneratingThumbnail ? 'Generating...' : 'Generate Thumbnail'}
|
||||
@@ -353,15 +341,9 @@ export function SettingsPanel({ projectId, projectVisibility, onVisibilityChange
|
||||
|
||||
{/* Save/Load Section */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">
|
||||
Save & Load
|
||||
</label>
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Save & Load</label>
|
||||
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleSaveBuild}
|
||||
variant="outline"
|
||||
>
|
||||
<Button className="w-full justify-start gap-2" onClick={handleSaveBuild} variant="outline">
|
||||
<Save className="size-4" />
|
||||
Save Build
|
||||
</Button>
|
||||
@@ -386,25 +368,19 @@ export function SettingsPanel({ projectId, projectVisibility, onVisibilityChange
|
||||
|
||||
{/* Audio Section */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">
|
||||
Audio
|
||||
</label>
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Audio</label>
|
||||
<AudioSettingsDialog />
|
||||
</div>
|
||||
|
||||
{/* Keyboard Section */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">
|
||||
Keyboard
|
||||
</label>
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Keyboard</label>
|
||||
<KeyboardShortcutsDialog />
|
||||
</div>
|
||||
|
||||
{/* Scene Graph */}
|
||||
<div className="space-y-1">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">
|
||||
Scene Graph
|
||||
</label>
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Scene Graph</label>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="h-auto justify-start p-0 text-sm" variant="link">
|
||||
@@ -414,7 +390,7 @@ export function SettingsPanel({ projectId, projectVisibility, onVisibilityChange
|
||||
<DialogContent className="h-[80vh] max-w-[95vw] gap-0 overflow-hidden border-0 bg-[#1e1e1e] p-0 shadow-none sm:max-w-5xl">
|
||||
<DialogTitle className="sr-only">Scene Graph</DialogTitle>
|
||||
<div
|
||||
className="flex h-full w-full min-h-0 min-w-0 *:h-full *:w-full *:overflow-y-auto"
|
||||
className="flex h-full min-h-0 w-full min-w-0 *:h-full *:w-full *:overflow-y-auto"
|
||||
onContextMenuCapture={blockSceneGraphMutations}
|
||||
onDragStartCapture={blockSceneGraphMutations}
|
||||
onDropCapture={blockSceneGraphMutations}
|
||||
@@ -430,9 +406,7 @@ export function SettingsPanel({ projectId, projectVisibility, onVisibilityChange
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-destructive text-xs uppercase">
|
||||
Danger Zone
|
||||
</label>
|
||||
<label className="font-medium text-destructive text-xs uppercase">Danger Zone</label>
|
||||
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
@@ -444,5 +418,5 @@ export function SettingsPanel({ projectId, projectVisibility, onVisibilityChange
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
+71
-73
@@ -1,6 +1,6 @@
|
||||
import { Keyboard } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "./../../../../../components/ui/primitives/button";
|
||||
import { Keyboard } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button } from './../../../../../components/ui/primitives/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,128 +8,126 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "./../../../../../components/ui/primitives/dialog";
|
||||
} from './../../../../../components/ui/primitives/dialog'
|
||||
|
||||
type Shortcut = {
|
||||
keys: string[];
|
||||
action: string;
|
||||
note?: string;
|
||||
};
|
||||
keys: string[]
|
||||
action: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
type ShortcutCategory = {
|
||||
title: string;
|
||||
shortcuts: Shortcut[];
|
||||
};
|
||||
title: string
|
||||
shortcuts: Shortcut[]
|
||||
}
|
||||
|
||||
const KEY_DISPLAY_MAP: Record<string, string> = {
|
||||
"Arrow Up": "↑",
|
||||
"Arrow Down": "↓",
|
||||
Esc: "⎋",
|
||||
Shift: "⇧",
|
||||
Space: "␣",
|
||||
};
|
||||
'Arrow Up': '↑',
|
||||
'Arrow Down': '↓',
|
||||
Esc: '⎋',
|
||||
Shift: '⇧',
|
||||
Space: '␣',
|
||||
}
|
||||
|
||||
const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
{
|
||||
title: "Editor Navigation",
|
||||
title: 'Editor Navigation',
|
||||
shortcuts: [
|
||||
{ keys: ["1"], action: "Switch to Site phase" },
|
||||
{ keys: ["2"], action: "Switch to Structure phase" },
|
||||
{ keys: ["3"], action: "Switch to Furnish phase" },
|
||||
{ keys: ["S"], action: "Switch to Structure layer" },
|
||||
{ keys: ["F"], action: "Switch to Furnish layer" },
|
||||
{ keys: ["Z"], action: "Switch to Zones layer" },
|
||||
{ keys: ['1'], action: 'Switch to Site phase' },
|
||||
{ keys: ['2'], action: 'Switch to Structure phase' },
|
||||
{ keys: ['3'], action: 'Switch to Furnish phase' },
|
||||
{ keys: ['S'], action: 'Switch to Structure layer' },
|
||||
{ keys: ['F'], action: 'Switch to Furnish layer' },
|
||||
{ keys: ['Z'], action: 'Switch to Zones layer' },
|
||||
{
|
||||
keys: ["Cmd/Ctrl", "Arrow Up"],
|
||||
action: "Select next level in the active building",
|
||||
keys: ['Cmd/Ctrl', 'Arrow Up'],
|
||||
action: 'Select next level in the active building',
|
||||
},
|
||||
{
|
||||
keys: ["Cmd/Ctrl", "Arrow Down"],
|
||||
action: "Select previous level in the active building",
|
||||
keys: ['Cmd/Ctrl', 'Arrow Down'],
|
||||
action: 'Select previous level in the active building',
|
||||
},
|
||||
{ keys: ["Cmd/Ctrl", "B"], action: "Toggle sidebar" },
|
||||
{ keys: ['Cmd/Ctrl', 'B'], action: 'Toggle sidebar' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Modes & History",
|
||||
title: 'Modes & History',
|
||||
shortcuts: [
|
||||
{ keys: ["V"], action: "Switch to Select mode" },
|
||||
{ keys: ["B"], action: "Switch to Build mode" },
|
||||
{ keys: ['V'], action: 'Switch to Select mode' },
|
||||
{ keys: ['B'], action: 'Switch to Build mode' },
|
||||
{
|
||||
keys: ["Esc"],
|
||||
action: "Cancel active tool, clear selection, and exit build mode",
|
||||
keys: ['Esc'],
|
||||
action: 'Cancel active tool, clear selection, and exit build mode',
|
||||
},
|
||||
{ keys: ["Delete / Backspace"], action: "Delete selected objects" },
|
||||
{ keys: ["Cmd/Ctrl", "Z"], action: "Undo" },
|
||||
{ keys: ["Cmd/Ctrl", "Shift", "Z"], action: "Redo" },
|
||||
{ keys: ['Delete / Backspace'], action: 'Delete selected objects' },
|
||||
{ keys: ['Cmd/Ctrl', 'Z'], action: 'Undo' },
|
||||
{ keys: ['Cmd/Ctrl', 'Shift', 'Z'], action: 'Redo' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Selection",
|
||||
title: 'Selection',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: ["Cmd/Ctrl", "Click"],
|
||||
action: "Add or remove an object from multi-selection",
|
||||
note: "Works while in Select mode.",
|
||||
keys: ['Cmd/Ctrl', 'Click'],
|
||||
action: 'Add or remove an object from multi-selection',
|
||||
note: 'Works while in Select mode.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Drawing Tools",
|
||||
title: 'Drawing Tools',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: ["Shift"],
|
||||
action: "Temporarily disable angle snapping while drawing walls, slabs, and ceilings",
|
||||
note: "Hold while drawing.",
|
||||
keys: ['Shift'],
|
||||
action: 'Temporarily disable angle snapping while drawing walls, slabs, and ceilings',
|
||||
note: 'Hold while drawing.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Item Placement",
|
||||
title: 'Item Placement',
|
||||
shortcuts: [
|
||||
{ keys: ["R"], action: "Rotate item clockwise by 90 degrees" },
|
||||
{ keys: ["T"], action: "Rotate item counter-clockwise by 90 degrees" },
|
||||
{ keys: ['R'], action: 'Rotate item clockwise by 90 degrees' },
|
||||
{ keys: ['T'], action: 'Rotate item counter-clockwise by 90 degrees' },
|
||||
{
|
||||
keys: ["Shift"],
|
||||
action: "Temporarily bypass placement validation constraints",
|
||||
note: "Hold while placing.",
|
||||
keys: ['Shift'],
|
||||
action: 'Temporarily bypass placement validation constraints',
|
||||
note: 'Hold while placing.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Camera",
|
||||
title: 'Camera',
|
||||
shortcuts: [
|
||||
{
|
||||
keys: ["Space", "Drag"],
|
||||
action: "Pan camera",
|
||||
note: "Hold Space while dragging with the mouse.",
|
||||
keys: ['Space', 'Drag'],
|
||||
action: 'Pan camera',
|
||||
note: 'Hold Space while dragging with the mouse.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
function getDisplayKey(key: string, isMac: boolean): string {
|
||||
if (key === "Cmd/Ctrl") return isMac ? "⌘" : "Ctrl";
|
||||
if (key === "Delete / Backspace") return isMac ? "⌫" : "Backspace";
|
||||
return KEY_DISPLAY_MAP[key] ?? key;
|
||||
if (key === 'Cmd/Ctrl') return isMac ? '⌘' : 'Ctrl'
|
||||
if (key === 'Delete / Backspace') return isMac ? '⌫' : 'Backspace'
|
||||
return KEY_DISPLAY_MAP[key] ?? key
|
||||
}
|
||||
|
||||
function ShortcutKeys({ keys }: { keys: string[] }) {
|
||||
const [isMac, setIsMac] = useState(true);
|
||||
const [isMac, setIsMac] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setIsMac(navigator.platform.toUpperCase().indexOf("MAC") >= 0);
|
||||
}, []);
|
||||
setIsMac(navigator.platform.toUpperCase().indexOf('MAC') >= 0)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{keys.map((key, index) => (
|
||||
<div key={`${key}-${index}`} className="flex items-center gap-1">
|
||||
{index > 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground">+</span>
|
||||
) : null}
|
||||
<div className="flex items-center gap-1" key={`${key}-${index}`}>
|
||||
{index > 0 ? <span className="text-[10px] text-muted-foreground">+</span> : null}
|
||||
<kbd
|
||||
className="inline-flex h-6 items-center rounded border border-border bg-muted px-2 font-mono text-[11px] font-medium text-muted-foreground"
|
||||
className="inline-flex h-6 items-center rounded border border-border bg-muted px-2 font-medium font-mono text-[11px] text-muted-foreground"
|
||||
title={key}
|
||||
>
|
||||
{getDisplayKey(key, isMac)}
|
||||
@@ -137,7 +135,7 @@ function ShortcutKeys({ keys }: { keys: string[] }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
@@ -149,7 +147,7 @@ export function KeyboardShortcutsDialog() {
|
||||
Keyboard Shortcuts
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[85vh] flex flex-col overflow-hidden p-0 sm:max-w-3xl">
|
||||
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden p-0 sm:max-w-3xl">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4">
|
||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -157,15 +155,15 @@ export function KeyboardShortcutsDialog() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
|
||||
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||
{SHORTCUT_CATEGORIES.map((category) => (
|
||||
<section key={category.title} className="space-y-2">
|
||||
<section className="space-y-2" key={category.title}>
|
||||
<h3 className="font-medium text-sm">{category.title}</h3>
|
||||
<div className="overflow-hidden rounded-md border border-border/80">
|
||||
{category.shortcuts.map((shortcut, index) => (
|
||||
<div
|
||||
key={`${category.title}-${shortcut.action}`}
|
||||
className="grid grid-cols-[minmax(130px,220px)_1fr] gap-3 px-3 py-2"
|
||||
key={`${category.title}-${shortcut.action}`}
|
||||
>
|
||||
<ShortcutKeys keys={shortcut.keys} />
|
||||
<div>
|
||||
@@ -185,5 +183,5 @@ export function KeyboardShortcutsDialog() {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
+40
-35
@@ -1,74 +1,79 @@
|
||||
import { BuildingNode, LevelNode, useScene } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { Building2, Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { TreeNode, TreeNodeWrapper } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
import { type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Building2, Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "./../../../../../components/ui/primitives/tooltip";
|
||||
} from './../../../../../components/ui/primitives/tooltip'
|
||||
import { TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface BuildingTreeNodeProps {
|
||||
node: BuildingNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
node: BuildingNode
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function BuildingTreeNode({ node, depth, isLast }: BuildingTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const createNode = useScene((state) => state.createNode);
|
||||
const isSelected = useViewer((state) => state.selection.buildingId === node.id);
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const createNode = useScene((state) => state.createNode)
|
||||
const isSelected = useViewer((state) => state.selection.buildingId === node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
|
||||
const handleClick = () => {
|
||||
setSelection({ buildingId: node.id });
|
||||
};
|
||||
setSelection({ buildingId: node.id })
|
||||
}
|
||||
|
||||
const handleAddLevel = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.stopPropagation()
|
||||
const newLevel = LevelNode.parse({
|
||||
level: node.children.length,
|
||||
children: [],
|
||||
parentId: node.id,
|
||||
});
|
||||
createNode(newLevel, node.id);
|
||||
};
|
||||
})
|
||||
createNode(newLevel, node.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
icon={<Building2 className="w-3.5 h-3.5" />}
|
||||
label={node.name || "Building"}
|
||||
depth={depth}
|
||||
hasChildren={node.children.length > 0}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
onClick={handleClick}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
actions={
|
||||
<div className="flex items-center gap-0.5">
|
||||
<TreeNodeActions node={node} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="w-5 h-5 flex items-center justify-center rounded hover:bg-primary-foreground/20"
|
||||
className="flex h-5 w-5 items-center justify-center rounded hover:bg-primary-foreground/20"
|
||||
onClick={handleAddLevel}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Add new level</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
depth={depth}
|
||||
expanded={expanded}
|
||||
hasChildren={node.children.length > 0}
|
||||
icon={<Building2 className="h-3.5 w-3.5" />}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
label={node.name || 'Building'}
|
||||
onClick={handleClick}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
>
|
||||
{node.children.map((childId, index) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
|
||||
<TreeNode
|
||||
depth={depth + 1}
|
||||
isLast={index === node.children.length - 1}
|
||||
key={childId}
|
||||
nodeId={childId}
|
||||
/>
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,119 +1,126 @@
|
||||
import { type AnyNodeId, CeilingNode, useScene } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect } from "react";
|
||||
import useEditor from "./../../../../../store/use-editor";
|
||||
import { InlineRenameInput } from "./inline-rename-input";
|
||||
import { TreeNode, TreeNodeWrapper, handleTreeSelection } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
import { type AnyNodeId, type CeilingNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { useEffect, useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface CeilingTreeNodeProps {
|
||||
node: CeilingNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
node: CeilingNode
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function CeilingTreeNode({ node, depth, isLast }: CeilingTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||
const isSelected = selectedIds.includes(node.id);
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIds.length === 0) return;
|
||||
const nodes = useScene.getState().nodes;
|
||||
let isDescendant = false;
|
||||
if (selectedIds.length === 0) return
|
||||
const nodes = useScene.getState().nodes
|
||||
let isDescendant = false
|
||||
for (const id of selectedIds) {
|
||||
let current = nodes[id as AnyNodeId];
|
||||
let current = nodes[id as AnyNodeId]
|
||||
while (current && current.parentId) {
|
||||
if (current.parentId === node.id) {
|
||||
isDescendant = true;
|
||||
break;
|
||||
isDescendant = true
|
||||
break
|
||||
}
|
||||
current = nodes[current.parentId as AnyNodeId];
|
||||
current = nodes[current.parentId as AnyNodeId]
|
||||
}
|
||||
if (isDescendant) break;
|
||||
if (isDescendant) break
|
||||
}
|
||||
if (isDescendant) {
|
||||
setExpanded(true);
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [selectedIds, node.id]);
|
||||
}, [selectedIds, node.id])
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
|
||||
if (!handled && useEditor.getState().phase === "furnish") {
|
||||
useEditor.getState().setPhase("structure");
|
||||
e.stopPropagation()
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
|
||||
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id);
|
||||
};
|
||||
setHoveredId(node.id)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredId(null);
|
||||
};
|
||||
setHoveredId(null)
|
||||
}
|
||||
|
||||
// Calculate approximate area from polygon
|
||||
const area = calculatePolygonArea(node.polygon).toFixed(1);
|
||||
const defaultName = `Ceiling (${area}m²)`;
|
||||
const area = calculatePolygonArea(node.polygon).toFixed(1)
|
||||
const defaultName = `Ceiling (${area}m²)`
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
nodeId={node.id}
|
||||
icon={<Image src="/icons/ceiling.png" alt="" width={14} height={14} className="object-contain" />}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
depth={depth}
|
||||
expanded={expanded}
|
||||
hasChildren={node.children.length > 0}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/ceiling.png" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
node={node}
|
||||
isEditing={isEditing}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
/>
|
||||
}
|
||||
depth={depth}
|
||||
hasChildren={node.children.length > 0}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
nodeId={node.id}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isVisible={node.visible !== false}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
>
|
||||
{node.children.map((childId, index) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
|
||||
<TreeNode
|
||||
depth={depth + 1}
|
||||
isLast={index === node.children.length - 1}
|
||||
key={childId}
|
||||
nodeId={childId}
|
||||
/>
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the area of a polygon using the shoelace formula
|
||||
*/
|
||||
function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
||||
if (polygon.length < 3) return 0;
|
||||
if (polygon.length < 3) return 0
|
||||
|
||||
let area = 0;
|
||||
const n = polygon.length;
|
||||
let area = 0
|
||||
const n = polygon.length
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n;
|
||||
area += polygon[i]![0] * polygon[j]![1];
|
||||
area -= polygon[j]![0] * polygon[i]![1];
|
||||
const j = (i + 1) % n
|
||||
area += polygon[i]![0] * polygon[j]![1]
|
||||
area -= polygon[j]![0] * polygon[i]![1]
|
||||
}
|
||||
|
||||
return Math.abs(area) / 2;
|
||||
return Math.abs(area) / 2
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { DoorNode } from "@pascal-app/core"
|
||||
import { useViewer } from "@pascal-app/viewer"
|
||||
import Image from "next/image"
|
||||
import { useState } from "react"
|
||||
import useEditor from "./../../../../../store/use-editor"
|
||||
import { InlineRenameInput } from "./inline-rename-input"
|
||||
import { TreeNodeWrapper, handleTreeSelection } from "./tree-node"
|
||||
import { TreeNodeActions } from "./tree-node-actions"
|
||||
import type { DoorNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface DoorTreeNodeProps {
|
||||
node: DoorNode
|
||||
@@ -23,40 +23,42 @@ export function DoorTreeNode({ node, depth, isLast }: DoorTreeNodeProps) {
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
|
||||
const defaultName = "Door"
|
||||
const defaultName = 'Door'
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
nodeId={node.id}
|
||||
icon={<Image src="/icons/door.png" alt="" width={14} height={14} className="object-contain" />}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
depth={depth}
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/door.png" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
node={node}
|
||||
isEditing={isEditing}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
/>
|
||||
}
|
||||
depth={depth}
|
||||
hasChildren={false}
|
||||
expanded={false}
|
||||
onToggle={() => {}}
|
||||
nodeId={node.id}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
|
||||
if (!handled && useEditor.getState().phase === "furnish") {
|
||||
useEditor.getState().setPhase("structure")
|
||||
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => setIsEditing(true)}
|
||||
onMouseEnter={() => setHoveredId(node.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isVisible={node.visible !== false}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+47
-49
@@ -1,15 +1,15 @@
|
||||
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";
|
||||
import { type AnyNode, useScene } from '@pascal-app/core'
|
||||
import { Pencil } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from './../../../../../lib/utils'
|
||||
|
||||
interface InlineRenameInputProps {
|
||||
node: AnyNode;
|
||||
isEditing: boolean;
|
||||
onStopEditing: () => void;
|
||||
defaultName: string;
|
||||
className?: string;
|
||||
onStartEditing?: () => void;
|
||||
node: AnyNode
|
||||
isEditing: boolean
|
||||
onStopEditing: () => void
|
||||
defaultName: string
|
||||
className?: string
|
||||
onStartEditing?: () => void
|
||||
}
|
||||
|
||||
export function InlineRenameInput({
|
||||
@@ -20,79 +20,77 @@ export function InlineRenameInput({
|
||||
className,
|
||||
onStartEditing,
|
||||
}: InlineRenameInputProps) {
|
||||
const updateNode = useScene((s) => s.updateNode);
|
||||
const [value, setValue] = useState(node.name || "");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const [value, setValue] = useState(node.name || '')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
setValue(node.name || "");
|
||||
setValue(node.name || '')
|
||||
// Focus and select all text after a short delay
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
inputRef.current.focus()
|
||||
inputRef.current.select()
|
||||
}
|
||||
}, 0);
|
||||
}, 0)
|
||||
}
|
||||
}, [isEditing, node.name]);
|
||||
}, [isEditing, node.name])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
const trimmed = value.trim()
|
||||
if (trimmed !== node.name) {
|
||||
updateNode(node.id, { name: trimmed || undefined });
|
||||
updateNode(node.id, { name: trimmed || undefined })
|
||||
}
|
||||
onStopEditing();
|
||||
}, [value, node.id, node.name, updateNode, onStopEditing]);
|
||||
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 (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 h-5">
|
||||
<span
|
||||
className={cn("truncate border-b border-transparent", className)}
|
||||
>
|
||||
<div className="group/rename flex h-5 min-w-0 items-center gap-1">
|
||||
<span className={cn('truncate border-transparent border-b', 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"
|
||||
className="shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/rename:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStartEditing();
|
||||
e.stopPropagation()
|
||||
onStartEditing()
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'm-0 h-5 w-full flex-1 rounded-none border-primary/50 border-b bg-transparent px-0 py-0 text-foreground text-sm outline-none focus:border-primary',
|
||||
className,
|
||||
)}
|
||||
onBlur={handleSave}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={defaultName}
|
||||
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-5 text-sm",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,111 +1,117 @@
|
||||
import { type AnyNodeId, ItemNode, useScene } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect } from "react";
|
||||
import useEditor from "./../../../../../store/use-editor";
|
||||
import { InlineRenameInput } from "./inline-rename-input";
|
||||
import { TreeNode, TreeNodeWrapper, handleTreeSelection } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
import { type AnyNodeId, type ItemNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { useEffect, useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
const CATEGORY_ICONS: Record<string, string> = {
|
||||
door: "/icons/door.png",
|
||||
window: "/icons/window.png",
|
||||
furniture: "/icons/couch.png",
|
||||
appliance: "/icons/appliance.png",
|
||||
kitchen: "/icons/kitchen.png",
|
||||
bathroom: "/icons/bathroom.png",
|
||||
outdoor: "/icons/tree.png",
|
||||
};
|
||||
door: '/icons/door.png',
|
||||
window: '/icons/window.png',
|
||||
furniture: '/icons/couch.png',
|
||||
appliance: '/icons/appliance.png',
|
||||
kitchen: '/icons/kitchen.png',
|
||||
bathroom: '/icons/bathroom.png',
|
||||
outdoor: '/icons/tree.png',
|
||||
}
|
||||
|
||||
interface ItemTreeNodeProps {
|
||||
node: ItemNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
node: ItemNode
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function ItemTreeNode({ node, depth, isLast }: ItemTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png";
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||
const isSelected = selectedIds.includes(node.id);
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const iconSrc = CATEGORY_ICONS[node.asset.category] || '/icons/couch.png'
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIds.length === 0) return;
|
||||
const nodes = useScene.getState().nodes;
|
||||
let isDescendant = false;
|
||||
if (selectedIds.length === 0) return
|
||||
const nodes = useScene.getState().nodes
|
||||
let isDescendant = false
|
||||
for (const id of selectedIds) {
|
||||
let current = nodes[id as AnyNodeId];
|
||||
let current = nodes[id as AnyNodeId]
|
||||
while (current && current.parentId) {
|
||||
if (current.parentId === node.id) {
|
||||
isDescendant = true;
|
||||
break;
|
||||
isDescendant = true
|
||||
break
|
||||
}
|
||||
current = nodes[current.parentId as AnyNodeId];
|
||||
current = nodes[current.parentId as AnyNodeId]
|
||||
}
|
||||
if (isDescendant) break;
|
||||
if (isDescendant) break
|
||||
}
|
||||
if (isDescendant) {
|
||||
setExpanded(true);
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [selectedIds, node.id]);
|
||||
}, [selectedIds, node.id])
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
|
||||
if (!handled && useEditor.getState().phase === "structure") {
|
||||
useEditor.getState().setPhase("furnish");
|
||||
e.stopPropagation()
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
|
||||
if (!handled && useEditor.getState().phase === 'structure') {
|
||||
useEditor.getState().setPhase('furnish')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id);
|
||||
};
|
||||
setHoveredId(node.id)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredId(null);
|
||||
};
|
||||
setHoveredId(null)
|
||||
}
|
||||
|
||||
const defaultName = node.asset.name || "Item";
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const defaultName = node.asset.name || 'Item'
|
||||
const hasChildren = node.children && node.children.length > 0
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
nodeId={node.id}
|
||||
icon={<Image src={iconSrc} alt="" width={14} height={14} className="object-contain" />}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
depth={depth}
|
||||
expanded={expanded}
|
||||
hasChildren={hasChildren}
|
||||
icon={<Image alt="" className="object-contain" height={14} src={iconSrc} width={14} />}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
node={node}
|
||||
isEditing={isEditing}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
/>
|
||||
}
|
||||
depth={depth}
|
||||
hasChildren={hasChildren}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
nodeId={node.id}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isVisible={node.visible !== false}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
>
|
||||
{hasChildren && node.children.map((childId, index) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
|
||||
))}
|
||||
{hasChildren &&
|
||||
node.children.map((childId, index) => (
|
||||
<TreeNode
|
||||
depth={depth + 1}
|
||||
isLast={index === node.children.length - 1}
|
||||
key={childId}
|
||||
nodeId={childId}
|
||||
/>
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,60 +1,65 @@
|
||||
import { LevelNode } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { Layers } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { InlineRenameInput } from "./inline-rename-input";
|
||||
import { TreeNode, TreeNodeWrapper } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
import type { LevelNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Layers } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface LevelTreeNodeProps {
|
||||
node: LevelNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
node: LevelNode
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function LevelTreeNode({ node, depth, isLast }: LevelTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const isSelected = useViewer((state) => state.selection.levelId === node.id);
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const isSelected = useViewer((state) => state.selection.levelId === node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
|
||||
const handleClick = () => {
|
||||
setSelection({ levelId: node.id });
|
||||
};
|
||||
setSelection({ levelId: node.id })
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const defaultName = `Level ${node.level}`;
|
||||
const defaultName = `Level ${node.level}`
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
icon={<Layers className="w-3.5 h-3.5" />}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
node={node}
|
||||
isEditing={isEditing}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
defaultName={defaultName}
|
||||
/>
|
||||
}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
depth={depth}
|
||||
hasChildren={node.children.length > 0}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
isSelected={isSelected}
|
||||
hasChildren={node.children.length > 0}
|
||||
icon={<Layers className="h-3.5 w-3.5" />}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
isSelected={isSelected}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
/>
|
||||
}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
>
|
||||
{node.children.map((childId, index) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
|
||||
<TreeNode
|
||||
depth={depth + 1}
|
||||
isLast={index === node.children.length - 1}
|
||||
key={childId}
|
||||
nodeId={childId}
|
||||
/>
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,77 +1,79 @@
|
||||
import { RoofNode } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import useEditor from "./../../../../../store/use-editor";
|
||||
import { InlineRenameInput } from "./inline-rename-input";
|
||||
import { TreeNodeWrapper, handleTreeSelection } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
import type { RoofNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface RoofTreeNodeProps {
|
||||
node: RoofNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
node: RoofNode
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||
const isSelected = selectedIds.includes(node.id);
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
|
||||
if (!handled && useEditor.getState().phase === "furnish") {
|
||||
useEditor.getState().setPhase("structure");
|
||||
e.stopPropagation()
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
|
||||
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id);
|
||||
};
|
||||
setHoveredId(node.id)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredId(null);
|
||||
};
|
||||
setHoveredId(null)
|
||||
}
|
||||
|
||||
// Calculate dimensions: length × total width (leftWidth + rightWidth)
|
||||
const totalWidth = node.leftWidth + node.rightWidth;
|
||||
const sizeLabel = `${node.length.toFixed(1)}×${totalWidth.toFixed(1)}m`;
|
||||
const defaultName = `Roof (${sizeLabel})`;
|
||||
const totalWidth = node.leftWidth + node.rightWidth
|
||||
const sizeLabel = `${node.length.toFixed(1)}×${totalWidth.toFixed(1)}m`
|
||||
const defaultName = `Roof (${sizeLabel})`
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
nodeId={node.id}
|
||||
icon={<Image src="/icons/roof.png" alt="" width={14} height={14} className="object-contain" />}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
depth={depth}
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/roof.png" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
node={node}
|
||||
isEditing={isEditing}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
/>
|
||||
}
|
||||
depth={depth}
|
||||
hasChildren={false}
|
||||
expanded={false}
|
||||
onToggle={() => {}}
|
||||
nodeId={node.id}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isVisible={node.visible !== false}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,94 +1,96 @@
|
||||
import { SlabNode } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import useEditor from "./../../../../../store/use-editor";
|
||||
import { InlineRenameInput } from "./inline-rename-input";
|
||||
import { TreeNodeWrapper, handleTreeSelection } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
import type { SlabNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface SlabTreeNodeProps {
|
||||
node: SlabNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
node: SlabNode
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function SlabTreeNode({ node, depth, isLast }: SlabTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||
const isSelected = selectedIds.includes(node.id);
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
|
||||
if (!handled && useEditor.getState().phase === "furnish") {
|
||||
useEditor.getState().setPhase("structure");
|
||||
e.stopPropagation()
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
|
||||
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id);
|
||||
};
|
||||
setHoveredId(node.id)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredId(null);
|
||||
};
|
||||
setHoveredId(null)
|
||||
}
|
||||
|
||||
// Calculate approximate area from polygon
|
||||
const area = calculatePolygonArea(node.polygon).toFixed(1);
|
||||
const defaultName = `Slab (${area}m²)`;
|
||||
const area = calculatePolygonArea(node.polygon).toFixed(1)
|
||||
const defaultName = `Slab (${area}m²)`
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
nodeId={node.id}
|
||||
icon={<Image src="/icons/floor.png" alt="" width={14} height={14} className="object-contain" />}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
depth={depth}
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/floor.png" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
node={node}
|
||||
isEditing={isEditing}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
/>
|
||||
}
|
||||
depth={depth}
|
||||
hasChildren={false}
|
||||
expanded={false}
|
||||
onToggle={() => {}}
|
||||
nodeId={node.id}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isVisible={node.visible !== false}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the area of a polygon using the shoelace formula
|
||||
*/
|
||||
function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
||||
if (polygon.length < 3) return 0;
|
||||
if (polygon.length < 3) return 0
|
||||
|
||||
let area = 0;
|
||||
const n = polygon.length;
|
||||
let area = 0
|
||||
const n = polygon.length
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n;
|
||||
area += polygon[i]![0] * polygon[j]![1];
|
||||
area -= polygon[j]![0] * polygon[i]![1];
|
||||
const j = (i + 1) % n
|
||||
area += polygon[i]![0] * polygon[j]![1]
|
||||
area -= polygon[j]![0] * polygon[i]![1]
|
||||
}
|
||||
|
||||
return Math.abs(area) / 2;
|
||||
return Math.abs(area) / 2
|
||||
}
|
||||
|
||||
@@ -1,113 +1,109 @@
|
||||
import { type AnyNode, type AnyNodeId, emitter, useScene } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { Camera, Eye, EyeOff, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { type AnyNode, type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Camera, Eye, EyeOff, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "./../../../../../components/ui/primitives/popover";
|
||||
} from './../../../../../components/ui/primitives/popover'
|
||||
|
||||
interface TreeNodeActionsProps {
|
||||
node: AnyNode;
|
||||
node: AnyNode
|
||||
}
|
||||
|
||||
export function TreeNodeActions({ node }: TreeNodeActionsProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const updateNode = useScene((state) => state.updateNode);
|
||||
const updateNodes = useScene((state) => state.updateNodes);
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||
const hasCamera = !!node.camera;
|
||||
const isVisible = node.visible !== false;
|
||||
const [open, setOpen] = useState(false)
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const updateNodes = useScene((state) => state.updateNodes)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const hasCamera = !!node.camera
|
||||
const isVisible = node.visible !== false
|
||||
|
||||
const toggleVisibility = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newVisibility = !isVisible;
|
||||
e.stopPropagation()
|
||||
const newVisibility = !isVisible
|
||||
if (selectedIds && selectedIds.includes(node.id)) {
|
||||
updateNodes(
|
||||
selectedIds.map((id) => ({
|
||||
id: id as AnyNodeId,
|
||||
data: { visible: newVisibility },
|
||||
}))
|
||||
);
|
||||
})),
|
||||
)
|
||||
} else {
|
||||
updateNode(node.id, { visible: newVisibility });
|
||||
updateNode(node.id, { visible: newVisibility })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleCaptureCamera = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
emitter.emit("camera-controls:capture", { nodeId: node.id });
|
||||
setOpen(false);
|
||||
};
|
||||
e.stopPropagation()
|
||||
emitter.emit('camera-controls:capture', { nodeId: node.id })
|
||||
setOpen(false)
|
||||
}
|
||||
const handleViewCamera = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
emitter.emit("camera-controls:view", { nodeId: node.id });
|
||||
setOpen(false);
|
||||
};
|
||||
e.stopPropagation()
|
||||
emitter.emit('camera-controls:view', { nodeId: node.id })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleClearCamera = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
updateNode(node.id, { camera: undefined });
|
||||
setOpen(false);
|
||||
};
|
||||
e.stopPropagation()
|
||||
updateNode(node.id, { camera: undefined })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
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"
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10"
|
||||
onClick={toggleVisibility}
|
||||
title={isVisible ? "Hide" : "Show"}
|
||||
title={isVisible ? 'Hide' : 'Show'}
|
||||
>
|
||||
{isVisible ? (
|
||||
<Eye className="w-3 h-3" />
|
||||
) : (
|
||||
<EyeOff className="w-3 h-3 opacity-50" />
|
||||
)}
|
||||
{isVisible ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3 opacity-50" />}
|
||||
</button>
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
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"
|
||||
className="relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Camera snapshot"
|
||||
>
|
||||
<Camera className="w-3 h-3" />
|
||||
<Camera className="h-3 w-3" />
|
||||
{hasCamera && (
|
||||
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
<span className="absolute top-0.5 right-0.5 h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="w-auto p-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
side="right"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{hasCamera && (
|
||||
<button
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent"
|
||||
onClick={handleViewCamera}
|
||||
>
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
<Camera className="h-3.5 w-3.5" />
|
||||
View snapshot
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-accent"
|
||||
onClick={handleCaptureCamera}
|
||||
>
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
{hasCamera ? "Update snapshot" : "Take snapshot"}
|
||||
<Camera className="h-3.5 w-3.5" />
|
||||
{hasCamera ? 'Update snapshot' : 'Take snapshot'}
|
||||
</button>
|
||||
{hasCamera && (
|
||||
<button
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-destructive hover:text-destructive-foreground text-left w-full"
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-popover-foreground text-sm hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={handleClearCamera}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Clear snapshot
|
||||
</button>
|
||||
)}
|
||||
@@ -115,5 +111,5 @@ export function TreeNodeActions({ node }: TreeNodeActionsProps) {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,229 +1,236 @@
|
||||
import { AnyNodeId, useScene } from "@pascal-app/core";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { forwardRef, useEffect, useRef } from "react";
|
||||
import { cn } from "./../../../../../lib/utils";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { forwardRef, useEffect, useRef } from 'react'
|
||||
import { cn } from './../../../../../lib/utils'
|
||||
|
||||
export function handleTreeSelection(
|
||||
e: React.MouseEvent,
|
||||
nodeId: string,
|
||||
selectedIds: string[],
|
||||
setSelection: (s: any) => void
|
||||
setSelection: (s: any) => void,
|
||||
) {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
if (selectedIds.includes(nodeId)) {
|
||||
setSelection({ selectedIds: selectedIds.filter((id) => id !== nodeId) });
|
||||
setSelection({ selectedIds: selectedIds.filter((id) => id !== nodeId) })
|
||||
} else {
|
||||
setSelection({ selectedIds: [...selectedIds, nodeId] });
|
||||
setSelection({ selectedIds: [...selectedIds, nodeId] })
|
||||
}
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
if (e.shiftKey && selectedIds.length > 0) {
|
||||
const lastSelectedId = selectedIds[selectedIds.length - 1];
|
||||
const lastSelectedId = selectedIds[selectedIds.length - 1]
|
||||
if (lastSelectedId) {
|
||||
const nodes = Array.from(document.querySelectorAll('[data-treenode-id]'));
|
||||
const nodeIds = nodes.map(n => n.getAttribute('data-treenode-id') as string);
|
||||
|
||||
const startIndex = nodeIds.indexOf(lastSelectedId);
|
||||
const endIndex = nodeIds.indexOf(nodeId);
|
||||
|
||||
const nodes = Array.from(document.querySelectorAll('[data-treenode-id]'))
|
||||
const nodeIds = nodes.map((n) => n.getAttribute('data-treenode-id') as string)
|
||||
|
||||
const startIndex = nodeIds.indexOf(lastSelectedId)
|
||||
const endIndex = nodeIds.indexOf(nodeId)
|
||||
|
||||
if (startIndex !== -1 && endIndex !== -1) {
|
||||
const start = Math.min(startIndex, endIndex);
|
||||
const end = Math.max(startIndex, endIndex);
|
||||
const range = nodeIds.slice(start, end + 1);
|
||||
|
||||
// We can keep the previous selections that were outside the range if we want,
|
||||
const start = Math.min(startIndex, endIndex)
|
||||
const end = Math.max(startIndex, endIndex)
|
||||
const range = nodeIds.slice(start, end + 1)
|
||||
|
||||
// We can keep the previous selections that were outside the range if we want,
|
||||
// but standard file system shift-click replaces the selection with the range.
|
||||
setSelection({ selectedIds: range });
|
||||
return true;
|
||||
setSelection({ selectedIds: range })
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Fallback: if range selection fails (e.g. node not visible in tree), just add to selection
|
||||
if (!selectedIds.includes(nodeId)) {
|
||||
setSelection({ selectedIds: [...selectedIds, nodeId] });
|
||||
return true;
|
||||
setSelection({ selectedIds: [...selectedIds, nodeId] })
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
setSelection({ selectedIds: [nodeId] });
|
||||
return false;
|
||||
setSelection({ selectedIds: [nodeId] })
|
||||
return false
|
||||
}
|
||||
import { BuildingTreeNode } from "./building-tree-node";
|
||||
import { CeilingTreeNode } from "./ceiling-tree-node";
|
||||
import { DoorTreeNode } from "./door-tree-node";
|
||||
import { ItemTreeNode } from "./item-tree-node";
|
||||
import { LevelTreeNode } from "./level-tree-node";
|
||||
import { RoofTreeNode } from "./roof-tree-node";
|
||||
import { SlabTreeNode } from "./slab-tree-node";
|
||||
import { WallTreeNode } from "./wall-tree-node";
|
||||
import { WindowTreeNode } from "./window-tree-node";
|
||||
import { ZoneTreeNode } from "./zone-tree-node";
|
||||
|
||||
import { BuildingTreeNode } from './building-tree-node'
|
||||
import { CeilingTreeNode } from './ceiling-tree-node'
|
||||
import { DoorTreeNode } from './door-tree-node'
|
||||
import { ItemTreeNode } from './item-tree-node'
|
||||
import { LevelTreeNode } from './level-tree-node'
|
||||
import { RoofTreeNode } from './roof-tree-node'
|
||||
import { SlabTreeNode } from './slab-tree-node'
|
||||
import { WallTreeNode } from './wall-tree-node'
|
||||
import { WindowTreeNode } from './window-tree-node'
|
||||
import { ZoneTreeNode } from './zone-tree-node'
|
||||
|
||||
interface TreeNodeProps {
|
||||
nodeId: AnyNodeId;
|
||||
depth?: number;
|
||||
isLast?: boolean;
|
||||
nodeId: AnyNodeId
|
||||
depth?: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
|
||||
const node = useScene((state) => state.nodes[nodeId]);
|
||||
const node = useScene((state) => state.nodes[nodeId])
|
||||
|
||||
if (!node) return null;
|
||||
if (!node) return null
|
||||
|
||||
switch (node.type) {
|
||||
case "building":
|
||||
return <BuildingTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "ceiling":
|
||||
return <CeilingTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "level":
|
||||
return <LevelTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "slab":
|
||||
return <SlabTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "wall":
|
||||
return <WallTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "roof":
|
||||
return <RoofTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "item":
|
||||
return <ItemTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "door":
|
||||
return <DoorTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "window":
|
||||
return <WindowTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case "zone":
|
||||
return <ZoneTreeNode node={node as any} depth={depth} isLast={isLast} />;
|
||||
case 'building':
|
||||
return <BuildingTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
case 'ceiling':
|
||||
return <CeilingTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
case 'level':
|
||||
return <LevelTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
case 'slab':
|
||||
return <SlabTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
case 'wall':
|
||||
return <WallTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
case 'roof':
|
||||
return <RoofTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
case 'item':
|
||||
return <ItemTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
case 'door':
|
||||
return <DoorTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
case 'window':
|
||||
return <WindowTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
case 'zone':
|
||||
return <ZoneTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
default:
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface TreeNodeWrapperProps {
|
||||
nodeId?: string;
|
||||
icon: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
depth: number;
|
||||
hasChildren: boolean;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onDoubleClick?: () => void;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
actions?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
isSelected?: boolean;
|
||||
isHovered?: boolean;
|
||||
isVisible?: boolean;
|
||||
isLast?: boolean;
|
||||
nodeId?: string
|
||||
icon: React.ReactNode
|
||||
label: React.ReactNode
|
||||
depth: number
|
||||
hasChildren: boolean
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
onDoubleClick?: () => void
|
||||
onMouseEnter?: () => void
|
||||
onMouseLeave?: () => void
|
||||
actions?: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
isSelected?: boolean
|
||||
isHovered?: boolean
|
||||
isVisible?: boolean
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
||||
function TreeNodeWrapper(
|
||||
{
|
||||
nodeId,
|
||||
icon,
|
||||
label,
|
||||
depth,
|
||||
hasChildren,
|
||||
expanded,
|
||||
onToggle,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
actions,
|
||||
children,
|
||||
isSelected,
|
||||
isHovered,
|
||||
isVisible = true,
|
||||
isLast,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
function TreeNodeWrapper(
|
||||
{
|
||||
nodeId,
|
||||
icon,
|
||||
label,
|
||||
depth,
|
||||
hasChildren,
|
||||
expanded,
|
||||
onToggle,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
actions,
|
||||
children,
|
||||
isSelected,
|
||||
isHovered,
|
||||
isVisible = true,
|
||||
isLast,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const rowRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected && rowRef.current) {
|
||||
rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}, [isSelected]);
|
||||
useEffect(() => {
|
||||
if (isSelected && rowRef.current) {
|
||||
rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
}, [isSelected])
|
||||
|
||||
return (
|
||||
<div ref={ref} data-treenode-id={nodeId}>
|
||||
return (
|
||||
<div data-treenode-id={nodeId} ref={ref}>
|
||||
<div
|
||||
className={cn(
|
||||
'group/row relative flex h-8 cursor-pointer select-none items-center border-border/50 border-r border-r-transparent border-b text-sm transition-all duration-200',
|
||||
isSelected
|
||||
? 'border-r-3 border-r-white bg-accent/50 text-foreground'
|
||||
: isHovered
|
||||
? 'bg-accent/30 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
|
||||
!isVisible && 'opacity-50',
|
||||
)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
ref={rowRef}
|
||||
style={{ paddingLeft: depth * 12 + 12, paddingRight: 12 }}
|
||||
>
|
||||
{/* Vertical tree line */}
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={cn(
|
||||
"relative flex items-center h-8 cursor-pointer group/row text-sm select-none border-b border-r border-border/50 border-r-transparent transition-all duration-200",
|
||||
isSelected
|
||||
? "bg-accent/50 text-foreground border-r-white border-r-3"
|
||||
: isHovered
|
||||
? "bg-accent/30 text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground",
|
||||
!isVisible && "opacity-50"
|
||||
'pointer-events-none absolute w-px bg-border/50',
|
||||
isLast ? 'top-0 bottom-1/2' : 'top-0 bottom-0',
|
||||
)}
|
||||
style={{ paddingLeft: depth * 12 + 12, paddingRight: 12 }}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{/* Vertical tree line */}
|
||||
style={{ left: (depth - 1) * 12 + 20 }}
|
||||
/>
|
||||
{/* Horizontal branch line */}
|
||||
<div
|
||||
className="pointer-events-none absolute top-1/2 h-px bg-border/50"
|
||||
style={{ left: (depth - 1) * 12 + 20, width: 4 }}
|
||||
/>
|
||||
{/* Line down to children */}
|
||||
{hasChildren && expanded && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-px bg-border/50 pointer-events-none",
|
||||
isLast ? "top-0 bottom-1/2" : "top-0 bottom-0"
|
||||
)}
|
||||
style={{ left: (depth - 1) * 12 + 20 }}
|
||||
className="pointer-events-none absolute top-1/2 bottom-0 w-px bg-border/50"
|
||||
style={{ left: depth * 12 + 20 }}
|
||||
/>
|
||||
{/* Horizontal branch line */}
|
||||
<div
|
||||
className="absolute top-1/2 h-px bg-border/50 pointer-events-none"
|
||||
style={{ left: (depth - 1) * 12 + 20, width: 4 }}
|
||||
/>
|
||||
{/* Line down to children */}
|
||||
{hasChildren && expanded && (
|
||||
<div
|
||||
className="absolute top-1/2 bottom-0 w-px bg-border/50 pointer-events-none"
|
||||
style={{ left: depth * 12 + 20 }}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
|
||||
<button
|
||||
className="w-4 h-4 flex items-center justify-center shrink-0 z-10 bg-inherit"
|
||||
<button
|
||||
className="z-10 flex h-4 w-4 shrink-0 items-center justify-center bg-inherit"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
e.stopPropagation()
|
||||
onToggle()
|
||||
}}
|
||||
>
|
||||
{hasChildren ? (
|
||||
expanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)
|
||||
) : null}
|
||||
</button>
|
||||
<div
|
||||
className="flex items-center gap-1.5 flex-1 min-w-0"
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5"
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<span className={cn(
|
||||
"w-4 h-4 flex items-center justify-center shrink-0 transition-all duration-200",
|
||||
!isSelected && "opacity-60 grayscale"
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-4 w-4 shrink-0 items-center justify-center transition-all duration-200',
|
||||
!isSelected && 'opacity-60 grayscale',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<div className={cn(
|
||||
"flex-1 min-w-0 truncate",
|
||||
!isVisible && "line-through text-muted-foreground"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate',
|
||||
!isVisible && 'text-muted-foreground line-through',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
{actions && (
|
||||
<div className={cn(
|
||||
"opacity-0 group-hover/row:opacity-100 pr-1 transition-opacity duration-200",
|
||||
!isVisible && "opacity-100"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'pr-1 opacity-0 transition-opacity duration-200 group-hover/row:opacity-100',
|
||||
!isVisible && 'opacity-100',
|
||||
)}
|
||||
>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
@@ -231,17 +238,17 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
||||
<AnimatePresence initial={false}>
|
||||
{expanded && children && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ type: "spring", bounce: 0, duration: 0.3 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
className="overflow-hidden"
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,99 +1,106 @@
|
||||
import { type AnyNodeId, WallNode, useScene } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect } from "react";
|
||||
import useEditor from "./../../../../../store/use-editor";
|
||||
import { InlineRenameInput } from "./inline-rename-input";
|
||||
import { TreeNode, TreeNodeWrapper, handleTreeSelection } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
import { type AnyNodeId, useScene, type WallNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { useEffect, useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface WallTreeNodeProps {
|
||||
node: WallNode;
|
||||
depth: number;
|
||||
isLast?: boolean;
|
||||
node: WallNode
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function WallTreeNode({ node, depth, isLast }: WallTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||
const isSelected = selectedIds.includes(node.id);
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId);
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIds.length === 0) return;
|
||||
const nodes = useScene.getState().nodes;
|
||||
let isDescendant = false;
|
||||
if (selectedIds.length === 0) return
|
||||
const nodes = useScene.getState().nodes
|
||||
let isDescendant = false
|
||||
for (const id of selectedIds) {
|
||||
let current = nodes[id as AnyNodeId];
|
||||
let current = nodes[id as AnyNodeId]
|
||||
while (current && current.parentId) {
|
||||
if (current.parentId === node.id) {
|
||||
isDescendant = true;
|
||||
break;
|
||||
isDescendant = true
|
||||
break
|
||||
}
|
||||
current = nodes[current.parentId as AnyNodeId];
|
||||
current = nodes[current.parentId as AnyNodeId]
|
||||
}
|
||||
if (isDescendant) break;
|
||||
if (isDescendant) break
|
||||
}
|
||||
if (isDescendant) {
|
||||
setExpanded(true);
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [selectedIds, node.id]);
|
||||
}, [selectedIds, node.id])
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection);
|
||||
if (!handled && useEditor.getState().phase === "furnish") {
|
||||
useEditor.getState().setPhase("structure");
|
||||
e.stopPropagation()
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
|
||||
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id);
|
||||
};
|
||||
setHoveredId(node.id)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredId(null);
|
||||
};
|
||||
setHoveredId(null)
|
||||
}
|
||||
|
||||
const defaultName = "Wall";
|
||||
const defaultName = 'Wall'
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
nodeId={node.id}
|
||||
icon={<Image src="/icons/wall.png" alt="" width={14} height={14} className="object-contain" />}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
depth={depth}
|
||||
expanded={expanded}
|
||||
hasChildren={node.children.length > 0}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/wall.png" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
node={node}
|
||||
isEditing={isEditing}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
/>
|
||||
}
|
||||
depth={depth}
|
||||
hasChildren={node.children.length > 0}
|
||||
expanded={expanded}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
nodeId={node.id}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
isVisible={node.visible !== false}
|
||||
isLast={isLast}
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
>
|
||||
{node.children.map((childId, index) => (
|
||||
<TreeNode key={childId} nodeId={childId} depth={depth + 1} isLast={index === node.children.length - 1} />
|
||||
<TreeNode
|
||||
depth={depth + 1}
|
||||
isLast={index === node.children.length - 1}
|
||||
key={childId}
|
||||
nodeId={childId}
|
||||
/>
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user