remove community

This commit is contained in:
wass08
2026-03-13 09:07:46 +01:00
parent 3eeba322a8
commit f2501be2bf
693 changed files with 8264 additions and 35562 deletions
+3 -5
View File
@@ -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>
+19 -13
View File
@@ -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>
)
+54 -47
View File
@@ -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>
+33 -33
View File
@@ -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 }
@@ -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>
);
)
}
@@ -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>
);
)
}
@@ -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
@@ -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