feat(editor): redesign panel UI with shared control components (#127)

Extract reusable control components (SliderControl, MetricControl, SegmentedControl,
ToggleControl, ActionButton, PanelSection) and PanelWrapper to reduce duplication
across all property panels. Net reduction of ~500 lines.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-03-01 02:35:19 -05:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 2ddf10f3f0
commit f1d0d3a78c
17 changed files with 2026 additions and 1721 deletions
@@ -0,0 +1,31 @@
'use client'
import { cn } from '@/lib/utils'
interface ActionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon?: React.ReactNode
label: string
}
export function ActionButton({ icon, label, className, ...props }: ActionButtonProps) {
return (
<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
)}
>
{icon}
<span>{label}</span>
</button>
)
}
export function ActionGroup({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex gap-1.5", className)}>
{children}
</div>
)
}
@@ -0,0 +1,246 @@
'use client'
import { useScene } from '@pascal-app/core'
import { useCallback, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
interface MetricControlProps {
label: React.ReactNode
value: number
onChange: (value: number) => void
min?: number
max?: number
precision?: number
step?: number
className?: string
unit?: string
}
export function MetricControl({
label,
value,
onChange,
min = -Infinity,
max = Infinity,
precision = 2,
step = 1,
className,
unit = '',
}: MetricControlProps) {
const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [inputValue, setInputValue] = useState(value.toFixed(precision))
const startXRef = useRef(0)
const startValueRef = useRef(0)
const containerRef = useRef<HTMLDivElement>(null)
const valueRef = useRef(value)
valueRef.current = value
const clamp = useCallback(
(val: number) => {
return Math.min(Math.max(val, min), max)
},
[min, max],
)
useEffect(() => {
if (!isEditing) {
setInputValue(value.toFixed(precision))
}
}, [value, precision, isEditing])
useEffect(() => {
const container = containerRef.current
if (!container) return
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
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)
}
}
container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel)
}, [isEditing, step, clamp, onChange, precision])
useEffect(() => {
if (!isHovered || isEditing) return
const handleKeyDown = (e: KeyboardEvent) => {
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)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isHovered, isEditing, step, clamp, onChange, precision])
const handlePointerDown = useCallback(
(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
const deltaValue = deltaX * dragStep
const newValue = clamp(startValueRef.current + deltaValue)
const newFinalValue = Number.parseFloat(newValue.toFixed(precision))
if (newFinalValue !== finalValue) {
finalValue = newFinalValue
onChange(finalValue)
}
}
const handlePointerUp = () => {
setIsDragging(false)
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerup', handlePointerUp)
if (finalValue !== startValueRef.current) {
onChange(startValueRef.current)
useScene.temporal.getState().resume()
onChange(finalValue)
} else {
useScene.temporal.getState().resume()
}
}
document.addEventListener('pointermove', handlePointerMove)
document.addEventListener('pointerup', handlePointerUp)
},
[isEditing, value, onChange, clamp, precision, step]
)
const handleValueClick = useCallback(() => {
setIsEditing(true)
setInputValue(value.toFixed(precision))
}, [value, precision])
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
}, [])
const submitValue = useCallback(() => {
const numValue = Number.parseFloat(inputValue)
if (!Number.isNaN(numValue)) {
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
} else {
setInputValue(value.toFixed(precision))
}
setIsEditing(false)
}, [inputValue, onChange, clamp, precision, value])
const handleInputBlur = useCallback(() => {
submitValue()
}, [submitValue])
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
submitValue()
} else if (e.key === 'Escape') {
setInputValue(value.toFixed(precision))
setIsEditing(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const newV = clamp(value + step)
onChange(newV)
setInputValue(newV.toFixed(precision))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const newV = clamp(value - step)
onChange(newV)
setInputValue(newV.toFixed(precision))
}
},
[submitValue, value, precision, step, clamp, onChange],
)
return (
<div
ref={containerRef}
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)}
>
<div
className={cn(
"text-muted-foreground select-none truncate transition-colors",
isDragging ? "cursor-ew-resize text-foreground" : "hover:text-foreground hover:cursor-ew-resize"
)}
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"
onBlur={handleInputBlur}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
type="text"
value={inputValue}
/>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
</div>
) : (
<div
className="flex w-full cursor-text items-center justify-end text-foreground hover:text-primary transition-colors"
onClick={handleValueClick}
>
<span className="font-mono tabular-nums tracking-tight">
{Number(value.toFixed(precision)).toFixed(precision)}
</span>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,67 @@
'use client'
import { cn } from '@/lib/utils'
import { ChevronDown } from 'lucide-react'
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
interface PanelSectionProps {
title: string
children: React.ReactNode
defaultExpanded?: boolean
className?: string
}
export function PanelSection({
title,
children,
defaultExpanded = true,
className,
}: PanelSectionProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
return (
<motion.div
layout
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
className={cn("flex flex-col shrink-0 overflow-hidden border-b border-border/50", className)}
>
<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",
isExpanded
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
)}
>
<span className="font-medium text-sm truncate">{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"
)}
/>
</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 }}
className="overflow-hidden"
>
<div className="flex flex-col gap-1.5 p-3 pt-2">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
}
@@ -0,0 +1,40 @@
'use client'
import { cn } from '@/lib/utils'
interface SegmentedControlProps<T extends string> {
value: T
onChange: (value: T) => void
options: { label: React.ReactNode; value: T }[]
className?: string
}
export function SegmentedControl<T extends string>({
value,
onChange,
options,
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)}>
{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",
isSelected
? "bg-[#3e3e3e] text-foreground shadow-sm ring-1 ring-border/50"
: "text-muted-foreground hover:bg-white/5 hover:text-foreground"
)}
>
<span className="relative z-10 flex items-center gap-1.5">{option.label}</span>
</button>
)
})}
</div>
)
}
@@ -0,0 +1,305 @@
'use client'
import { useScene } from '@pascal-app/core'
import { useCallback, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
interface SliderControlProps {
label: React.ReactNode
value: number
onChange: (value: number) => void
min?: number
max?: number
precision?: number
step?: number
className?: string
unit?: string
}
export function SliderControl({
label,
value,
onChange,
min = 0,
max = 100,
precision = 0,
step = 1,
className,
unit = '',
}: SliderControlProps) {
const [isEditing, setIsEditing] = useState(false)
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)
const valueRef = useRef(value)
valueRef.current = value
const clamp = useCallback(
(val: number) => {
return Math.min(Math.max(val, min), max)
},
[min, max],
)
useEffect(() => {
if (!isEditing) {
setInputValue(value.toFixed(precision))
}
}, [value, precision, isEditing])
useEffect(() => {
const container = containerRef.current
if (!container) return
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
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)
}
}
container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel)
}, [isEditing, step, clamp, onChange, precision])
useEffect(() => {
if (!isHovered || isEditing) return
const handleKeyDown = (e: KeyboardEvent) => {
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)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isHovered, isEditing, step, clamp, onChange, precision])
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if (isEditing) return
e.preventDefault()
const track = trackRef.current
if (!track) return
setIsDragging(true)
setDragStartValue(value)
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))
const rawValue = min + percent * (max - min)
// snap to step
const snapped = Math.round(rawValue / step) * step
const finalValue = Number.parseFloat(clamp(snapped).toFixed(precision))
onChange(finalValue)
}
updateValueFromEvent(e.clientX)
const handlePointerMove = (moveEvent: PointerEvent) => {
updateValueFromEvent(moveEvent.clientX)
}
const handlePointerUp = (e: PointerEvent) => {
// Only stop dragging if we didn't release on the reset button
// Let the reset button's onPointerDown handle its own cleanup
if ((e.target as HTMLElement).closest('button')) {
return
}
setIsDragging(false)
setDragStartValue(null)
setDragMin(null)
setDragMax(null)
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerup', handlePointerUp)
useScene.temporal.getState().resume()
}
document.addEventListener('pointermove', handlePointerMove)
document.addEventListener('pointerup', handlePointerUp)
},
[isEditing, min, max, step, precision, clamp, onChange]
)
const handleValueClick = useCallback(() => {
setIsEditing(true)
setInputValue(value.toFixed(precision))
}, [value, precision])
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
}, [])
const submitValue = useCallback(() => {
const numValue = Number.parseFloat(inputValue)
if (!Number.isNaN(numValue)) {
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
} else {
setInputValue(value.toFixed(precision))
}
setIsEditing(false)
}, [inputValue, onChange, clamp, precision, value])
const handleInputBlur = useCallback(() => {
submitValue()
}, [submitValue])
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
submitValue()
} else if (e.key === 'Escape') {
setInputValue(value.toFixed(precision))
setIsEditing(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const newV = clamp(value + step)
onChange(newV)
setInputValue(newV.toFixed(precision))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const newV = clamp(value - step)
onChange(newV)
setInputValue(newV.toFixed(precision))
}
},
[submitValue, value, precision, step, clamp, onChange],
)
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
return (
<div
ref={containerRef}
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)}
>
{/* 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"
onPointerDown={(e) => {
e.stopPropagation()
onChange(dragStartValue)
setDragStartValue(null)
setDragMin(null)
setDragMax(null)
setIsDragging(false)
useScene.temporal.getState().resume()
}}
>
Reset
</button>
)}
<div className="w-[80px] shrink-0 text-muted-foreground select-none truncate">
{label}
</div>
<div
ref={trackRef}
className={cn(
"relative flex h-full flex-1 items-center justify-center touch-none mx-2",
isDragging ? "cursor-grabbing" : "cursor-grab"
)}
onPointerDown={handlePointerDown}
>
{/* Track dots background */}
<div className="absolute inset-x-0 flex items-center justify-between opacity-30 px-1 pointer-events-none">
{[...Array(9)].map((_, i) => (
<div key={i} className="h-[3px] w-[3px] rounded-full bg-current" />
))}
</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"
style={{ left: `${startPercent}%` }}
/>
)}
{/* Active Thumb */}
<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"
)}
style={{ left: `${percent}%` }}
/>
</div>
<div className="flex w-[50px] 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"
onBlur={handleInputBlur}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
type="text"
value={inputValue}
/>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
</div>
) : (
<div
className="flex w-full cursor-text items-center justify-end text-foreground/60 hover:text-foreground transition-colors"
onClick={handleValueClick}
>
<span className="font-mono tabular-nums tracking-tight">
{Number(value.toFixed(precision)).toFixed(precision)}
</span>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,40 @@
'use client'
import { cn } from '@/lib/utils'
import { Check } from 'lucide-react'
interface ToggleControlProps {
label: string
checked: boolean
onChange: (checked: boolean) => void
className?: string
}
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)}
onClick={() => onChange(!checked)}
>
<div className="text-muted-foreground transition-colors group-hover:text-foreground select-none">
{label}
</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"
)}
>
<Check className="h-3.5 w-3.5" strokeWidth={3} />
</div>
</div>
)
}
@@ -2,11 +2,14 @@
import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core' import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Edit, Plus, Trash2, X } from 'lucide-react' import { Edit, Plus, Trash2 } from 'lucide-react'
import Image from 'next/image'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import useEditor from '@/store/use-editor' import useEditor from '@/store/use-editor'
import { NumberInput } from '@/components/ui/primitives/number-input'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { ActionButton } from '../controls/action-button'
export function CeilingPanel() { export function CeilingPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -16,7 +19,6 @@ export function CeilingPanel() {
const editingHole = useEditor((s) => s.editingHole) const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole) const setEditingHole = useEditor((s) => s.setEditingHole)
// Get the first selected node if it's a ceiling
const selectedId = selectedIds[0] const selectedId = selectedIds[0]
const node = selectedId const node = selectedId
? (nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) ? (nodes[selectedId as AnyNode['id']] as CeilingNode | undefined)
@@ -35,14 +37,12 @@ export function CeilingPanel() {
setEditingHole(null) setEditingHole(null)
}, [setSelection, setEditingHole]) }, [setSelection, setEditingHole])
// Clear hole editing state when ceiling is deselected
useEffect(() => { useEffect(() => {
if (!node) { if (!node) {
setEditingHole(null) setEditingHole(null)
} }
}, [node, setEditingHole]) }, [node, setEditingHole])
// Clear hole editing state on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
setEditingHole(null) setEditingHole(null)
@@ -52,7 +52,6 @@ export function CeilingPanel() {
const handleAddHole = useCallback(() => { const handleAddHole = useCallback(() => {
if (!node || !selectedId) return if (!node || !selectedId) return
// Calculate centroid of the ceiling polygon
const polygon = node.polygon const polygon = node.polygon
let cx = 0 let cx = 0
let cz = 0 let cz = 0
@@ -63,7 +62,6 @@ export function CeilingPanel() {
cx /= polygon.length cx /= polygon.length
cz /= polygon.length cz /= polygon.length
// Create a default small rectangular hole centered at the ceiling's centroid
const holeSize = 0.5 const holeSize = 0.5
const newHole: Array<[number, number]> = [ const newHole: Array<[number, number]> = [
[cx - holeSize, cz - holeSize], [cx - holeSize, cz - holeSize],
@@ -73,7 +71,6 @@ export function CeilingPanel() {
] ]
const currentHoles = node?.holes || [] const currentHoles = node?.holes || []
handleUpdate({ holes: [...currentHoles, newHole] }) handleUpdate({ holes: [...currentHoles, newHole] })
// Enter edit mode for the new hole
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length }) setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole]) }, [node, selectedId, handleUpdate, setEditingHole])
@@ -98,10 +95,8 @@ export function CeilingPanel() {
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole], [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
) )
// Only show if exactly one ceiling is selected
if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
// Calculate approximate area from polygon
const calculateArea = (polygon: Array<[number, number]>): number => { const calculateArea = (polygon: Array<[number, number]>): number => {
if (polygon.length < 3) return 0 if (polygon.length < 3) return 0
let area = 0 let area = 0
@@ -117,152 +112,81 @@ export function CeilingPanel() {
const area = calculateArea(node.polygon) const area = calculateArea(node.polygon)
return ( return (
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-72 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md"> <PanelWrapper
{/* Header */} title={node.name || "Ceiling"}
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent"> icon="/icons/ceiling.png"
<div className="flex items-center gap-2 min-w-0"> onClose={handleClose}
<Image src="/icons/ceiling.png" alt="" width={16} height={16} className="shrink-0 object-contain" /> width={320}
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || "Ceiling"}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
onClick={handleClose}
> >
<X className="h-4 w-4" /> <PanelSection title="Height">
</button> <SliderControl
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-4">
{/* Height */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Height
</label>
<div className="flex items-center gap-2">
<NumberInput
label="Height" label="Height"
value={Math.round(node.height * 1000) / 1000} value={Math.round(node.height * 1000) / 1000}
onChange={(value) => { onChange={(v) => handleUpdate({ height: v })}
handleUpdate({ height: value }) min={0}
}} max={6}
precision={3} precision={3}
className="flex-1" step={0.01}
unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<p className="text-xs text-muted-foreground">
Height from the floor where the ceiling is positioned
</p>
</div>
{/* Quick preset buttons */} <div className="mt-2 grid grid-cols-3 gap-1.5 px-1 pb-1">
<div className="space-y-2"> <ActionButton label="Low (2.4m)" onClick={() => handleUpdate({ height: 2.4 })} />
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide"> <ActionButton label="Standard (2.5m)" onClick={() => handleUpdate({ height: 2.5 })} />
Presets <ActionButton label="High (3.0m)" onClick={() => handleUpdate({ height: 3.0 })} />
</label>
<div className="grid grid-cols-3 gap-2">
<button
type="button"
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => handleUpdate({ height: 2.4 })}
>
Low (2.4m)
</button>
<button
type="button"
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => handleUpdate({ height: 2.5 })}
>
Standard (2.5m)
</button>
<button
type="button"
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => handleUpdate({ height: 3.0 })}
>
High (3m)
</button>
</div>
</div> </div>
</PanelSection>
{/* Area info */} <PanelSection title="Info">
<div className="space-y-2"> <div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide"> <span>Area</span>
Area <span className="font-mono text-white">{area.toFixed(2)} m²</span>
</label>
<div className="rounded-lg border border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-3 py-2 text-sm text-foreground">
{area.toFixed(2)} m²
</div>
</div> </div>
</PanelSection>
{/* Holes */} <PanelSection title="Holes">
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Holes
</label>
{editingHole?.nodeId === selectedId ? (
<button
type="button"
className="flex items-center gap-1 rounded-md border border-green-500 bg-green-500/10 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium text-green-600 hover:bg-green-500/20 transition-colors cursor-pointer"
onClick={() => setEditingHole(null)}
>
<span>Done Editing</span>
</button>
) : (
<button
type="button"
className="flex items-center gap-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleAddHole}
>
<Plus className="h-3 w-3" />
<span>Add Hole</span>
</button>
)}
</div>
{node.holes && node.holes.length > 0 ? ( {node.holes && node.holes.length > 0 ? (
<div className="space-y-2"> <div className="flex flex-col gap-1 pb-2">
{node.holes.map((hole, index) => { {node.holes.map((hole, index) => {
const holeArea = calculateArea(hole) const holeArea = calculateArea(hole)
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
return ( return (
<div <div
key={index} key={index}
className={`flex items-center justify-between rounded-lg border px-3 py-2 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] transition-colors ${ className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
isEditing isEditing
? 'border-green-500 bg-green-500/10 ring-1 ring-green-500/20' ? 'border-primary/50 bg-primary/10'
: 'border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30' : 'border-transparent hover:bg-accent/30'
}`} }`}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${isEditing ? 'text-green-600' : ''}`}> <p className={`text-xs font-medium ${isEditing ? 'text-primary' : 'text-white'}`}>
Hole {index + 1} {isEditing && '(Editing)'} Hole {index + 1} {isEditing && '(Editing)'}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} vertices {holeArea.toFixed(2)} m² · {hole.length} pts
</p> </p>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{!isEditing && ( {isEditing ? (
<ActionButton
label="Done"
onClick={() => setEditingHole(null)}
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
/>
) : (
<> <>
<button <button
type="button" type="button"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer" 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)} onClick={() => handleEditHole(index)}
aria-label="Edit hole"
> >
<Edit className="h-3.5 w-3.5" /> <Edit className="h-3.5 w-3.5" />
</button> </button>
<button <button
type="button" type="button"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer" 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)} onClick={() => handleDeleteHole(index)}
aria-label="Delete hole"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
@@ -274,13 +198,21 @@ export function CeilingPanel() {
})} })}
</div> </div>
) : ( ) : (
<p className="text-xs text-muted-foreground italic"> <div className="px-2 py-3 text-center text-xs text-muted-foreground">
No holes. Click "Add Hole" to create one. No holes
</p> </div>
)} )}
<div className="px-1 pt-1 pb-1">
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Hole"
onClick={handleAddHole}
className="w-full"
disabled={editingHole?.nodeId === selectedId}
/>
</div> </div>
</div> </PanelSection>
</div> </PanelWrapper>
</div>
) )
} }
+174 -312
View File
@@ -2,13 +2,18 @@
import { type AnyNode, type AnyNodeId, DoorNode, useScene } from '@pascal-app/core' import { type AnyNode, type AnyNodeId, DoorNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Copy, FlipHorizontal2, Move, Trash2, X } from 'lucide-react' import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import Image from 'next/image'
import { useCallback } from 'react' import { useCallback } from 'react'
import { sfxEmitter } from '@/lib/sfx-bus' import { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor' import useEditor from '@/store/use-editor'
import { NumberInput } from '@/components/ui/primitives/number-input'
import { Switch } from '@/components/ui/primitives/switch' 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'
export function DoorPanel() { export function DoorPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -136,236 +141,160 @@ export function DoorPanel() {
const normHeights = node.segments.map(seg => seg.heightRatio / hSum) const normHeights = node.segments.map(seg => seg.heightRatio / hSum)
return ( return (
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-82 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md max-h-[calc(100dvh-100px)]"> <PanelWrapper
{/* Header */} title={node.name || "Door"}
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent"> icon="/icons/door.png"
<div className="flex items-center gap-2 min-w-0"> onClose={handleClose}
<Image src="/icons/door.png" alt="" width={16} height={16} className="shrink-0 object-contain" /> width={320}
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || "Door"}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
onClick={handleClose}
> >
<X className="h-4 w-4" /> <PanelSection title="Position">
</button> <SliderControl
</div> label={<>X<sub className="text-[11px] ml-[1px] opacity-70">wall</sub></>}
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Position */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Position
</label>
<div className="grid grid-cols-1 gap-2">
<NumberInput
label="X along wall"
value={Math.round(node.position[0] * 100) / 100} value={Math.round(node.position[0] * 100) / 100}
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })} onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
min={-10}
max={10}
precision={2} precision={2}
step={0.1}
unit="m"
/>
<div className="pt-2 pb-1 px-1">
<ActionButton
icon={<FlipHorizontal2 className="h-4 w-4" />}
label="Flip Side"
onClick={handleFlip}
className="w-full"
/> />
</div> </div>
<button </PanelSection>
type="button"
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleFlip}
>
<FlipHorizontal2 className="h-3.5 w-3.5" />
Flip Side
</button>
</div>
{/* Dimensions */} <PanelSection title="Dimensions">
<div className="space-y-2"> <SliderControl
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Dimensions
</label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5">
<NumberInput
label="Width" label="Width"
value={Math.round(node.width * 100) / 100} value={Math.round(node.width * 100) / 100}
onChange={(v) => handleUpdate({ width: v })} onChange={(v) => handleUpdate({ width: v })}
min={0.5} min={0.5}
max={3}
precision={2} precision={2}
className="flex-1" step={0.05}
unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> <SliderControl
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Height" label="Height"
value={Math.round(node.height * 100) / 100} value={Math.round(node.height * 100) / 100}
onChange={(v) => handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })} onChange={(v) => handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })}
min={1.0} min={1.0}
max={4}
precision={2} precision={2}
className="flex-1" step={0.05}
unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> </PanelSection>
</div>
</div>
</div>
{/* Frame */} <PanelSection title="Frame">
<div className="space-y-2"> <SliderControl
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Frame
</label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5">
<NumberInput
label="Thickness" label="Thickness"
value={Math.round(node.frameThickness * 1000) / 1000} value={Math.round(node.frameThickness * 1000) / 1000}
onChange={(v) => handleUpdate({ frameThickness: v })} onChange={(v) => handleUpdate({ frameThickness: v })}
min={0.01} min={0.01}
max={0.2}
precision={3} precision={3}
step={0.01} step={0.01}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> <SliderControl
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Depth" label="Depth"
value={Math.round(node.frameDepth * 1000) / 1000} value={Math.round(node.frameDepth * 1000) / 1000}
onChange={(v) => handleUpdate({ frameDepth: v })} onChange={(v) => handleUpdate({ frameDepth: v })}
min={0.01} min={0.01}
max={0.3}
precision={3} precision={3}
step={0.01} step={0.01}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> </PanelSection>
</div>
</div>
</div>
{/* Content Padding */} <PanelSection title="Content Padding">
<div className="space-y-2"> <SliderControl
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Content Padding
</label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5">
<NumberInput
label="Horizontal" label="Horizontal"
value={Math.round(node.contentPadding[0] * 1000) / 1000} value={Math.round(node.contentPadding[0] * 1000) / 1000}
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })} onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
min={0} min={0}
max={0.2}
precision={3} precision={3}
step={0.005} step={0.005}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> <SliderControl
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Vertical" label="Vertical"
value={Math.round(node.contentPadding[1] * 1000) / 1000} value={Math.round(node.contentPadding[1] * 1000) / 1000}
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })} onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
min={0} min={0}
max={0.2}
precision={3} precision={3}
step={0.005} step={0.005}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> </PanelSection>
</div>
</div>
</div>
{/* Swing */} <PanelSection title="Swing">
<div className="space-y-2"> <div className="flex flex-col gap-2 px-1 pb-1">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Swing
</label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1"> <div className="space-y-1">
<span className="text-xs text-muted-foreground">Hinges</span> <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Hinges Side</span>
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg"> <SegmentedControl
{(['left', 'right'] as const).map((side) => ( value={node.hingesSide}
<button onChange={(v) => handleUpdate({ hingesSide: v })}
key={side} options={[
type="button" { label: 'Left', value: 'left' },
onClick={() => handleUpdate({ hingesSide: side })} { label: 'Right', value: 'right' },
className={`flex-1 rounded-md px-2 py-1 text-xs font-medium cursor-pointer transition-all duration-200 ${ ]}
node.hingesSide === side />
? 'bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50'
}`}
>
{side.charAt(0).toUpperCase() + side.slice(1)}
</button>
))}
</div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<span className="text-xs text-muted-foreground">Direction</span> <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Direction</span>
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg"> <SegmentedControl
{(['inward', 'outward'] as const).map((dir) => ( value={node.swingDirection}
<button onChange={(v) => handleUpdate({ swingDirection: v })}
key={dir} options={[
type="button" { label: 'Inward', value: 'inward' },
onClick={() => handleUpdate({ swingDirection: dir })} { label: 'Outward', value: 'outward' },
className={`flex-1 rounded border px-2 py-1 text-xs cursor-pointer transition-colors ${ ]}
node.swingDirection === dir />
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-accent'
}`}
>
{dir.charAt(0).toUpperCase() + dir.slice(1)}
</button>
))}
</div>
</div>
</div> </div>
</div> </div>
</PanelSection>
{/* Threshold */} <PanelSection title="Threshold">
<div className="space-y-2"> <ToggleControl
<div className="flex items-center justify-between"> label="Enable Threshold"
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Threshold
</label>
<Switch
checked={node.threshold} checked={node.threshold}
onCheckedChange={(checked) => handleUpdate({ threshold: checked })} onChange={(checked) => handleUpdate({ threshold: checked })}
/> />
</div>
{node.threshold && ( {node.threshold && (
<div className="flex items-center gap-1.5"> <div className="mt-1 flex flex-col gap-1">
<NumberInput <SliderControl
label="Height" label="Height"
value={Math.round(node.thresholdHeight * 1000) / 1000} value={Math.round(node.thresholdHeight * 1000) / 1000}
onChange={(v) => handleUpdate({ thresholdHeight: v })} onChange={(v) => handleUpdate({ thresholdHeight: v })}
min={0.005} min={0.005}
max={0.1}
precision={3} precision={3}
step={0.005} step={0.005}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div> </div>
)} )}
</div> </PanelSection>
{/* Handle */} <PanelSection title="Handle">
<div className="space-y-2"> <ToggleControl
<div className="flex items-center justify-between"> label="Enable Handle"
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Handle
</label>
<Switch
checked={node.handle} checked={node.handle}
onCheckedChange={(checked) => handleUpdate({ handle: checked })} onChange={(checked) => handleUpdate({ handle: checked })}
/> />
</div>
{node.handle && ( {node.handle && (
<div className="grid grid-cols-2 gap-2"> <div className="mt-1 flex flex-col gap-1">
<div className="flex items-center gap-1.5"> <SliderControl
<NumberInput
label="Height" label="Height"
value={Math.round(node.handleHeight * 100) / 100} value={Math.round(node.handleHeight * 100) / 100}
onChange={(v) => handleUpdate({ handleHeight: v })} onChange={(v) => handleUpdate({ handleHeight: v })}
@@ -373,108 +302,75 @@ export function DoorPanel() {
max={node.height - 0.1} max={node.height - 0.1}
precision={2} precision={2}
step={0.05} step={0.05}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="space-y-1"> <div className="space-y-1">
<span className="text-xs text-muted-foreground">Side</span> <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Handle Side</span>
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg"> <SegmentedControl
{(['left', 'right'] as const).map((side) => ( value={node.handleSide}
<button onChange={(v) => handleUpdate({ handleSide: v })}
key={side} options={[
type="button" { label: 'Left', value: 'left' },
onClick={() => handleUpdate({ handleSide: side })} { label: 'Right', value: 'right' },
className={`flex-1 rounded border px-2 py-1 text-xs cursor-pointer transition-colors ${ ]}
node.handleSide === side />
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-accent'
}`}
>
{side.charAt(0).toUpperCase() + side.slice(1)}
</button>
))}
</div>
</div> </div>
</div> </div>
)} )}
</div> </PanelSection>
{/* Hardware */} <PanelSection title="Hardware">
<div className="space-y-2"> <ToggleControl
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide"> label="Door Closer"
Hardware
</label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-foreground">Door Closer</span>
<Switch
checked={node.doorCloser} checked={node.doorCloser}
onCheckedChange={(checked) => handleUpdate({ doorCloser: checked })} onChange={(checked) => handleUpdate({ doorCloser: checked })}
/> />
</div> <ToggleControl
<div className="flex items-center justify-between"> label="Panic Bar"
<span className="text-sm text-foreground">Panic Bar</span>
<Switch
checked={node.panicBar} checked={node.panicBar}
onCheckedChange={(checked) => handleUpdate({ panicBar: checked })} onChange={(checked) => handleUpdate({ panicBar: checked })}
/> />
</div>
{node.panicBar && ( {node.panicBar && (
<div className="flex items-center gap-1.5"> <div className="mt-1 flex flex-col gap-1">
<NumberInput <SliderControl
label="Bar height" label="Bar Height"
value={Math.round(node.panicBarHeight * 100) / 100} value={Math.round(node.panicBarHeight * 100) / 100}
onChange={(v) => handleUpdate({ panicBarHeight: v })} onChange={(v) => handleUpdate({ panicBarHeight: v })}
min={0.5} min={0.5}
max={node.height - 0.1} max={node.height - 0.1}
precision={2} precision={2}
step={0.05} step={0.05}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div> </div>
)} )}
</div> </PanelSection>
</div>
{/* Segments */} <PanelSection title="Segments">
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Leaf segments (top bottom)
</label>
{node.segments.map((seg, i) => { {node.segments.map((seg, i) => {
const numCols = seg.columnRatios.length const numCols = seg.columnRatios.length
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0) 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 ( return (
<div key={i} className="rounded border border-border p-2 space-y-2"> <div key={i} className="mb-2 flex flex-col gap-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between pb-1">
<span className="text-xs text-muted-foreground">Segment {i + 1}</span> <span className="text-xs font-medium text-white/80">Segment {i + 1}</span>
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg"> </div>
{(['panel', 'glass', 'empty'] as const).map((t) => (
<button <SegmentedControl
key={t} value={seg.type}
type="button" onChange={(t) => {
onClick={() => { 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 }) handleUpdate({ segments: updated })
}} }}
className={`rounded border px-1.5 py-0.5 text-xs cursor-pointer transition-colors ${ options={[
seg.type === t { label: 'Panel', value: 'panel' },
? 'border-primary bg-primary text-primary-foreground' { label: 'Glass', value: 'glass' },
: 'border-border hover:bg-accent' { label: 'Empty', value: 'empty' },
}`} ]}
> />
{t}
</button> <SliderControl
))}
</div>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Height" label="Height"
value={Math.round(normHeights[i]! * 100 * 10) / 10} value={Math.round(normHeights[i]! * 100 * 10) / 10}
onChange={(v) => setSegmentHeightRatio(i, v / 100)} onChange={(v) => setSegmentHeightRatio(i, v / 100)}
@@ -482,13 +378,10 @@ export function DoorPanel() {
max={95} max={95}
precision={1} precision={1}
step={1} step={1}
className="flex-1" unit="%"
/> />
<span className="text-muted-foreground text-xs shrink-0">%</span>
</div> <SliderControl
{/* Columns */}
<div className="space-y-1">
<NumberInput
label="Columns" label="Columns"
value={numCols} value={numCols}
onChange={(v) => { onChange={(v) => {
@@ -503,11 +396,12 @@ export function DoorPanel() {
precision={0} precision={0}
step={1} step={1}
/> />
{numCols > 1 && ( {numCols > 1 && (
<div className="space-y-1 pl-1"> <div className="mt-1 border-t border-border/50 pt-1">
{normCols.map((ratio, ci) => ( {normCols.map((ratio, ci) => (
<div key={ci} className="flex items-center gap-1.5"> <SliderControl
<NumberInput key={`c-${ci}`}
label={`C${ci + 1}`} label={`C${ci + 1}`}
value={Math.round(ratio * 100 * 10) / 10} value={Math.round(ratio * 100 * 10) / 10}
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)} onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
@@ -515,13 +409,10 @@ export function DoorPanel() {
max={95} max={95}
precision={1} precision={1}
step={1} step={1}
className="flex-1" unit="%"
/> />
<span className="text-muted-foreground text-xs shrink-0">%</span>
</div>
))} ))}
<div className="flex items-center gap-1.5"> <SliderControl
<NumberInput
label="Divider" label="Divider"
value={Math.round(seg.dividerThickness * 1000) / 1000} value={Math.round(seg.dividerThickness * 1000) / 1000}
onChange={(v) => { onChange={(v) => {
@@ -531,19 +422,17 @@ export function DoorPanel() {
handleUpdate({ segments: updated }) handleUpdate({ segments: updated })
}} }}
min={0.005} min={0.005}
max={0.1}
precision={3} precision={3}
step={0.005} step={0.005}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div> </div>
)} )}
</div>
{seg.type === 'panel' && ( {seg.type === 'panel' && (
<div className="grid grid-cols-2 gap-2"> <div className="mt-1 border-t border-border/50 pt-1">
<div className="flex items-center gap-1.5"> <SliderControl
<NumberInput
label="Inset" label="Inset"
value={Math.round(seg.panelInset * 1000) / 1000} value={Math.round(seg.panelInset * 1000) / 1000}
onChange={(v) => { onChange={(v) => {
@@ -553,14 +442,12 @@ export function DoorPanel() {
handleUpdate({ segments: updated }) handleUpdate({ segments: updated })
}} }}
min={0} min={0}
max={0.1}
precision={3} precision={3}
step={0.005} step={0.005}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> <SliderControl
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Depth" label="Depth"
value={Math.round(seg.panelDepth * 1000) / 1000} value={Math.round(seg.panelDepth * 1000) / 1000}
onChange={(v) => { onChange={(v) => {
@@ -570,21 +457,20 @@ export function DoorPanel() {
handleUpdate({ segments: updated }) handleUpdate({ segments: updated })
}} }}
min={0} min={0}
max={0.1}
precision={3} precision={3}
step={0.005} step={0.005}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div> </div>
)} )}
</div> </div>
) )
})} })}
<div className="flex gap-2">
<button <div className="flex gap-1.5 px-1 pt-1">
type="button" <ActionButton
className="flex-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer" label="+ Add Segment"
onClick={() => { onClick={() => {
const updated = [ const updated = [
...node.segments, ...node.segments,
@@ -592,53 +478,29 @@ export function DoorPanel() {
] ]
handleUpdate({ segments: updated }) handleUpdate({ segments: updated })
}} }}
> />
+ Add segment
</button>
{node.segments.length > 1 && ( {node.segments.length > 1 && (
<button <ActionButton
type="button" label="- Remove"
className="flex-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer" onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
onClick={() => { className="text-white/60 hover:text-white"
handleUpdate({ segments: node.segments.slice(0, -1) }) />
}}
>
Remove last
</button>
)} )}
</div> </div>
</div> </PanelSection>
</div>
{/* Action Buttons */} <PanelSection title="Actions">
<div className="border-t border-border/50 p-3 bg-white/50 dark:bg-transparent"> <ActionGroup>
<div className="flex gap-2"> <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<button <ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
type="button" <ActionButton
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer" icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
onClick={handleMove} label="Delete"
>
<Move className="h-3.5 w-3.5" />
<span>Move</span>
</button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleDuplicate}
>
<Copy className="h-3.5 w-3.5" />
<span>Duplicate</span>
</button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleDelete} onClick={handleDelete}
> className="hover:bg-red-500/20"
<Trash2 className="h-3.5 w-3.5" /> />
<span>Delete</span> </ActionGroup>
</button> </PanelSection>
</div> </PanelWrapper>
</div>
</div>
) )
} }
+104 -154
View File
@@ -2,12 +2,17 @@
import { getScaledDimensions, type AnyNode, ItemNode, useScene } from '@pascal-app/core' import { getScaledDimensions, type AnyNode, ItemNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Copy, Link, Link2Off, Move, Trash2, X } from 'lucide-react' import { Copy, Link, Link2Off, Move, Trash2 } from 'lucide-react'
import Image from 'next/image'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import useEditor from '@/store/use-editor' import useEditor from '@/store/use-editor'
import { NumberInput } from '@/components/ui/primitives/number-input'
import { sfxEmitter } from '@/lib/sfx-bus' import { sfxEmitter } from '@/lib/sfx-bus'
import { cn } from '@/lib/utils'
import { PanelWrapper } from './panel-wrapper'
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'
export function ItemPanel() { export function ItemPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -17,7 +22,6 @@ export function ItemPanel() {
const deleteNode = useScene((s) => s.deleteNode) const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
// Get the first selected node if it's an item
const selectedId = selectedIds[0] const selectedId = selectedIds[0]
const node = selectedId const node = selectedId
? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined) ? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined)
@@ -30,7 +34,6 @@ export function ItemPanel() {
if (!selectedId || !node) return if (!selectedId || !node) return
updateNode(selectedId as AnyNode['id'], updates) updateNode(selectedId as AnyNode['id'], updates)
// Mark parent wall as dirty if item is attached to wall
if (node.asset.attachTo === 'wall' && node.parentId) { if (node.asset.attachTo === 'wall' && node.parentId) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
useScene.getState().dirtyNodes.add(node.parentId as AnyNode['id']) useScene.getState().dirtyNodes.add(node.parentId as AnyNode['id'])
@@ -48,7 +51,6 @@ export function ItemPanel() {
if (node) { if (node) {
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
setMovingNode(node) setMovingNode(node)
// Deselect so the panel closes
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
} }
}, [node, setMovingNode, setSelection]) }, [node, setMovingNode, setSelection])
@@ -56,8 +58,6 @@ export function ItemPanel() {
const handleDuplicate = useCallback(() => { const handleDuplicate = useCallback(() => {
if (!node) return if (!node) return
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
// Create a proto node (not added to scene) as a carrier for asset/position info.
// MoveItemContent detects metadata.isNew and uses draftNode.create() so ghost rendering works correctly.
const proto = ItemNode.parse({ const proto = ItemNode.parse({
position: [...node.position] as [number, number, number], position: [...node.position] as [number, number, number],
rotation: [...node.rotation] as [number, number, number], rotation: [...node.rotation] as [number, number, number],
@@ -78,221 +78,171 @@ export function ItemPanel() {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [selectedId, deleteNode, setSelection]) }, [selectedId, deleteNode, setSelection])
// Only show if exactly one item is selected
if (!node || node.type !== 'item' || selectedIds.length !== 1) return null if (!node || node.type !== 'item' || selectedIds.length !== 1) return null
return ( return (
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-72 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md"> <PanelWrapper
{/* Header */} title={node.name || node.asset.name}
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent"> icon={node.asset.thumbnail || '/icons/furniture.png'}
<div className="flex items-center gap-2 min-w-0"> onClose={handleClose}
<Image width={300}
src={node.asset.thumbnail || '/icons/furniture.png'}
alt=""
width={16}
height={16}
className="shrink-0 object-contain"
/>
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || node.asset.name}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
onClick={handleClose}
> >
<X className="h-4 w-4" /> <PanelSection title="Position">
</button> <SliderControl
</div> label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
{/* Content */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-4">
{/* Position */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Position
</label>
<div className="grid grid-cols-3 gap-2">
<NumberInput
label="X"
value={Math.round(node.position[0] * 100) / 100} value={Math.round(node.position[0] * 100) / 100}
onChange={(value) => { onChange={(value) => handleUpdate({ position: [value, node.position[1], node.position[2]] })}
handleUpdate({ position: [value, node.position[1], node.position[2]] }) min={node.position[0] - 2}
}} max={node.position[0] + 2}
precision={2} precision={2}
step={0.01}
unit="m"
/> />
<NumberInput <SliderControl
label="Y" label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[1] * 100) / 100} value={Math.round(node.position[1] * 100) / 100}
onChange={(value) => { onChange={(value) => handleUpdate({ position: [node.position[0], value, node.position[2]] })}
handleUpdate({ position: [node.position[0], value, node.position[2]] }) min={node.position[1] - 2}
}} max={node.position[1] + 2}
precision={2} precision={2}
step={0.01}
unit="m"
/> />
<NumberInput <SliderControl
label="Z" label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[2] * 100) / 100} value={Math.round(node.position[2] * 100) / 100}
onChange={(value) => { onChange={(value) => handleUpdate({ position: [node.position[0], node.position[1], value] })}
handleUpdate({ position: [node.position[0], node.position[1], value] }) min={node.position[2] - 2}
}} max={node.position[2] + 2}
precision={2} precision={2}
step={0.01}
unit="m"
/> />
</div> </PanelSection>
</div>
{/* Rotation */} <PanelSection title="Rotation">
<div className="space-y-2"> <SliderControl
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide"> label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
Rotation
</label>
<div className="flex items-center gap-2">
<NumberInput
label="Y"
value={Math.round((node.rotation[1] * 180) / Math.PI)} value={Math.round((node.rotation[1] * 180) / Math.PI)}
onChange={(degrees) => { onChange={(degrees) => {
const radians = (degrees * Math.PI) / 180 const radians = (degrees * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] }) 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} precision={0}
className="flex-1" step={1}
unit="°"
/> />
<span className="text-muted-foreground text-xs shrink-0">°</span> <div className="flex gap-1.5 px-1 pt-2 pb-1">
</div> <ActionButton
<div className="flex gap-2"> label="-45°"
<button
type="button"
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={() => { onClick={() => {
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
const currentDegrees = (node.rotation[1] * 180) / Math.PI const currentDegrees = (node.rotation[1] * 180) / Math.PI
const newDegrees = currentDegrees - 90 const radians = ((currentDegrees - 45) * Math.PI) / 180
const radians = (newDegrees * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] }) handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}} }}
> />
-90° <ActionButton
</button> label="+45°"
<button
type="button"
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={() => { onClick={() => {
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
const currentDegrees = (node.rotation[1] * 180) / Math.PI const currentDegrees = (node.rotation[1] * 180) / Math.PI
const newDegrees = currentDegrees + 90 const radians = ((currentDegrees + 45) * Math.PI) / 180
const radians = (newDegrees * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] }) handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}} }}
> />
+90°
</button>
</div>
</div> </div>
</PanelSection>
{/* Scale */} <PanelSection title="Scale">
<div className="space-y-2"> <div className="flex items-center justify-between px-2 pb-2">
<div className="flex items-center justify-between"> <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Uniform Scale</span>
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Scale
</label>
<button <button
type="button" type="button"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer" 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]"
)}
onClick={() => setUniformScale((v) => !v)} onClick={() => setUniformScale((v) => !v)}
title={uniformScale ? 'Unlock axes' : 'Lock axes'}
> >
{uniformScale ? <Link className="h-3.5 w-3.5" /> : <Link2Off className="h-3.5 w-3.5" />} {uniformScale ? <Link className="h-3.5 w-3.5" /> : <Link2Off className="h-3.5 w-3.5" />}
</button> </button>
</div> </div>
{uniformScale ? ( {uniformScale ? (
<NumberInput <SliderControl
label="XYZ" label={<>XYZ<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale[0] * 100) / 100} value={Math.round(node.scale[0] * 100) / 100}
onChange={(value) => { onChange={(value) => {
const v = Math.max(0.01, value) const v = Math.max(0.01, value)
handleUpdate({ scale: [v, v, v] }) handleUpdate({ scale: [v, v, v] })
}} }}
min={0.01}
max={10}
precision={2} precision={2}
step={0.1} step={0.1}
/> />
) : ( ) : (
<div className="grid grid-cols-3 gap-2"> <>
<NumberInput <SliderControl
label="X" label={<>X<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale[0] * 100) / 100} value={Math.round(node.scale[0] * 100) / 100}
onChange={(value) => { onChange={(value) => handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] })}
handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] }) min={0.01}
}} max={10}
precision={2} precision={2}
step={0.1} step={0.1}
/> />
<NumberInput <SliderControl
label="Y" label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale[1] * 100) / 100} value={Math.round(node.scale[1] * 100) / 100}
onChange={(value) => { onChange={(value) => handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] })}
handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] }) min={0.01}
}} max={10}
precision={2} precision={2}
step={0.1} step={0.1}
/> />
<NumberInput <SliderControl
label="Z" label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale[2] * 100) / 100} value={Math.round(node.scale[2] * 100) / 100}
onChange={(value) => { onChange={(value) => handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] })}
handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] }) min={0.01}
}} max={10}
precision={2} precision={2}
step={0.1} step={0.1}
/> />
</div> </>
)} )}
</div> </PanelSection>
{/* Dimensions (effective, read-only) */} <PanelSection title="Info">
<div className="space-y-2"> <div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide"> <span>Dimensions</span>
Dimensions
</label>
<div className="rounded-lg border border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-3 py-2 text-sm font-mono text-foreground">
{(() => { {(() => {
const [w, h, d] = getScaledDimensions(node) const [w, h, d] = getScaledDimensions(node)
return `${Math.round(w * 100) / 100}m × ${Math.round(h * 100) / 100}m × ${Math.round(d * 100) / 100}m` return (
<span className="font-mono text-white">
{Math.round(w * 100) / 100}×{Math.round(h * 100) / 100}×{Math.round(d * 100) / 100}
</span>
)
})()} })()}
</div> </div>
</div> </PanelSection>
</div>
</div>
{/* Action Buttons */} <PanelSection title="Actions">
<div className="border-t border-border/50 p-3 bg-white/50 dark:bg-transparent"> <ActionGroup>
<div className="flex gap-2"> <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<button <ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
type="button" <ActionButton
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer" icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
onClick={handleMove} label="Delete"
>
<Move className="h-3.5 w-3.5" />
<span>Move</span>
</button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleDuplicate}
>
<Copy className="h-3.5 w-3.5" />
<span>Duplicate</span>
</button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleDelete} onClick={handleDelete}
> className="hover:bg-red-500/20"
<Trash2 className="h-3.5 w-3.5" /> />
<span>Delete</span> </ActionGroup>
</button> </PanelSection>
</div> </PanelWrapper>
</div>
</div>
) )
} }
@@ -0,0 +1,79 @@
'use client'
import { cn } from '@/lib/utils'
import { X, RotateCcw, Moon } from 'lucide-react'
import Image from 'next/image'
interface PanelWrapperProps {
title: string
icon?: string
onClose?: () => void
onReset?: () => void
children: React.ReactNode
className?: string
width?: number | string
}
export function PanelWrapper({
title,
icon,
onClose,
onReset,
children,
className,
width = 320, // default width
}: PanelWrapperProps) {
return (
<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
)}
style={{ width }}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-3 border-b border-border/50">
<div className="flex items-center gap-2">
{icon && (
<Image
src={icon}
alt=""
width={16}
height={16}
className="shrink-0 object-contain"
/>
)}
<h2 className="font-semibold text-foreground text-sm truncate tracking-tight">
{title}
</h2>
</div>
<div className="flex items-center gap-1">
{onReset && (
<button
type="button"
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"
>
<RotateCcw className="h-4 w-4" />
</button>
)}
{onClose && (
<button
type="button"
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"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0 no-scrollbar flex flex-col">
{children}
</div>
</div>
)
}
@@ -1,10 +1,15 @@
'use client' 'use client'
import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-app/core' import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-app/core'
import { Box, Image, X } from 'lucide-react' import { Box, Image as ImageIcon } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import useEditor from '@/store/use-editor' import useEditor from '@/store/use-editor'
import { NumberInput } from '@/components/ui/primitives/number-input'
import { PanelWrapper } from './panel-wrapper'
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'
type ReferenceNode = ScanNode | GuideNode type ReferenceNode = ScanNode | GuideNode
@@ -35,60 +40,60 @@ export function ReferencePanel() {
const isScan = node.type === 'scan' const isScan = node.type === 'scan'
return ( return (
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-72 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md"> <PanelWrapper
{/* Header */} title={node.name || (isScan ? '3D Scan' : 'Guide Image')}
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent"> icon={isScan ? undefined : undefined}
<div className="flex items-center gap-2 min-w-0"> onClose={handleClose}
{isScan ? ( width={300}
<Box className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<Image className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || (isScan ? '3D Scan' : 'Guide Image')}
</h2>
</div>
<button
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
onClick={handleClose}
> >
<X className="h-4 w-4" /> <PanelSection title="Position">
</button> <SliderControl
</div> label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[0] * 100) / 100}
{/* Content */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-4">
{/* Position */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Position
</label>
<div className="grid grid-cols-3 gap-2">
{([0, 1, 2] as const).map((i) => (
<NumberInput
key={i}
label={['X', 'Y', 'Z'][i]!}
value={Math.round(node.position[i] * 100) / 100}
onChange={(value) => { onChange={(value) => {
const pos = [...node.position] as [number, number, number] const pos = [...node.position] as [number, number, number]
pos[i] = value pos[0] = value
handleUpdate({ position: pos }) handleUpdate({ position: pos })
}} }}
min={-50}
max={50}
precision={2} precision={2}
step={0.1}
unit="m"
/> />
))} <SliderControl
</div> label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
</div> value={Math.round(node.position[1] * 100) / 100}
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"
/>
<SliderControl
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[2] * 100) / 100}
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"
/>
</PanelSection>
{/* Rotation Y */} <PanelSection title="Rotation">
<div className="space-y-2"> <SliderControl
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide"> label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
Rotation
</label>
<div className="flex items-center gap-1.5">
<NumberInput
label="Y"
value={Math.round((node.rotation[1] * 180) / Math.PI)} value={Math.round((node.rotation[1] * 180) / Math.PI)}
onChange={(degrees) => { onChange={(degrees) => {
const radians = (degrees * Math.PI) / 180 const radians = (degrees * Math.PI) / 180
@@ -96,40 +101,27 @@ export function ReferencePanel() {
rotation: [node.rotation[0], radians, node.rotation[2]], rotation: [node.rotation[0], radians, node.rotation[2]],
}) })
}} }}
min={-180}
max={180}
precision={0} precision={0}
className="min-w-0 flex-1" step={1}
unit="°"
/>
<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]] })}
/> />
<span className="text-muted-foreground text-xs shrink-0">&deg;</span>
<button
className="shrink-0 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-1.5 py-1 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() =>
handleUpdate({
rotation: [node.rotation[0], node.rotation[1] - Math.PI / 4, node.rotation[2]],
})
}
>
&minus;45
</button>
<button
className="shrink-0 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-1.5 py-1 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() =>
handleUpdate({
rotation: [node.rotation[0], node.rotation[1] + Math.PI / 4, node.rotation[2]],
})
}
>
+45
</button>
</div>
</div> </div>
</PanelSection>
{/* Scale */} <PanelSection title="Scale & Opacity">
<div className="space-y-2"> <SliderControl
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide"> label={<>XYZ<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
Scale
</label>
<NumberInput
label="Scale"
value={Math.round(node.scale * 100) / 100} value={Math.round(node.scale * 100) / 100}
onChange={(value) => { onChange={(value) => {
if (value > 0) { if (value > 0) {
@@ -137,30 +129,22 @@ export function ReferencePanel() {
} }
}} }}
min={0.01} min={0.01}
max={10}
precision={2} precision={2}
step={0.1}
/> />
</div>
{/* Opacity */} <SliderControl
<div className="space-y-2"> label="Opacity"
<div className="flex justify-between">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Opacity
</label>
<span className="text-muted-foreground font-mono text-xs">{node.opacity}%</span>
</div>
<input
className="w-full cursor-pointer"
max="100"
min="0"
onChange={(e) => handleUpdate({ opacity: Number.parseInt(e.target.value, 10) })}
step="1"
type="range"
value={node.opacity} value={node.opacity}
onChange={(v) => handleUpdate({ opacity: v })}
min={0}
max={100}
precision={0}
step={1}
unit="%"
/> />
</div> </PanelSection>
</div> </PanelWrapper>
</div>
</div>
) )
} }
+111 -175
View File
@@ -2,17 +2,20 @@
import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core' import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { X } from 'lucide-react'
import Image from 'next/image'
import { useCallback } from 'react' import { useCallback } from 'react'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { MetricControl } from '../controls/metric-control'
import { ActionButton } from '../controls/action-button'
export function RoofPanel() { export function RoofPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes) const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
// Get the first selected node if it's a roof
const selectedId = selectedIds[0] const selectedId = selectedIds[0]
const node = selectedId const node = selectedId
? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined) ? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined)
@@ -30,204 +33,137 @@ export function RoofPanel() {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
// Only show if exactly one roof is selected
if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
// Calculate total width for display
const totalWidth = node.leftWidth + node.rightWidth const totalWidth = node.leftWidth + node.rightWidth
return ( return (
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-72 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md"> <PanelWrapper
{/* Header */} title={node.name || "Roof"}
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent"> icon="/icons/roof.png"
<div className="flex items-center gap-2 min-w-0"> onClose={handleClose}
<Image src="/icons/roof.png" alt="" width={16} height={16} className="shrink-0 object-contain" /> width={300}
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || "Roof"}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
onClick={handleClose}
> >
<X className="h-4 w-4" /> <PanelSection title="Dimensions">
</button> <SliderControl
</div> label="Length"
{/* Content */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-4">
{/* Length */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Length
</label>
<div className="flex items-center gap-2">
<input
className="flex-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
min="0.5"
onChange={(e) => {
const value = Number.parseFloat(e.target.value)
if (!Number.isNaN(value) && value > 0) {
handleUpdate({ length: value })
}
}}
step="0.5"
type="number"
value={Math.round(node.length * 100) / 100} value={Math.round(node.length * 100) / 100}
onChange={(v) => handleUpdate({ length: v })}
min={0.5}
max={20}
precision={2}
step={0.5}
unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> <SliderControl
</div> label="Height"
</div>
{/* Height */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Height
</label>
<div className="flex items-center gap-2">
<input
className="flex-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
min="0.1"
onChange={(e) => {
const value = Number.parseFloat(e.target.value)
if (!Number.isNaN(value) && value > 0) {
handleUpdate({ height: value })
}
}}
step="0.1"
type="number"
value={Math.round(node.height * 100) / 100} value={Math.round(node.height * 100) / 100}
onChange={(v) => handleUpdate({ height: v })}
min={0.1}
max={10}
precision={2}
step={0.1}
unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> </PanelSection>
</div>
</div>
{/* Slope Widths */} <PanelSection title="Slope Widths">
<div className="space-y-2"> <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"> <span>Widths</span>
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide"> <span>Total: {totalWidth.toFixed(1)}m</span>
Slope Widths
</label>
<span className="text-muted-foreground text-xs">
Total: {totalWidth.toFixed(1)}m
</span>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <SliderControl
<div className="space-y-1"> label="Left"
<label className="text-muted-foreground text-xs">Left</label>
<div className="flex items-center gap-1">
<input
className="w-full rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
min="0.1"
onChange={(e) => {
const value = Number.parseFloat(e.target.value)
if (!Number.isNaN(value) && value > 0) {
handleUpdate({ leftWidth: value })
}
}}
step="0.1"
type="number"
value={Math.round(node.leftWidth * 100) / 100} value={Math.round(node.leftWidth * 100) / 100}
onChange={(v) => handleUpdate({ leftWidth: v })}
min={0.1}
max={10}
precision={2}
step={0.1}
unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> <SliderControl
</div> label="Right"
</div>
<div className="space-y-1">
<label className="text-muted-foreground text-xs">Right</label>
<div className="flex items-center gap-1">
<input
className="w-full rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
min="0.1"
onChange={(e) => {
const value = Number.parseFloat(e.target.value)
if (!Number.isNaN(value) && value > 0) {
handleUpdate({ rightWidth: value })
}
}}
step="0.1"
type="number"
value={Math.round(node.rightWidth * 100) / 100} value={Math.round(node.rightWidth * 100) / 100}
onChange={(v) => handleUpdate({ rightWidth: v })}
min={0.1}
max={10}
precision={2}
step={0.1}
unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> </PanelSection>
</div>
</div>
</div>
</div>
{/* Rotation */} <PanelSection title="Rotation">
<div className="space-y-2"> <SliderControl
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide"> label={<>R<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
Rotation value={Math.round((node.rotation * 180) / Math.PI)}
</label> onChange={(degrees) => {
<div className="flex items-center gap-1.5">
<input
className="min-w-0 flex-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
onChange={(e) => {
const degrees = Number.parseFloat(e.target.value)
if (!Number.isNaN(degrees)) {
const radians = (degrees * Math.PI) / 180 const radians = (degrees * Math.PI) / 180
handleUpdate({ rotation: radians }) handleUpdate({ rotation: radians })
}
}} }}
step="1" min={-180}
type="number" max={180}
value={Math.round((node.rotation * 180) / Math.PI)} precision={0}
step={1}
unit="°"
/>
<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 })}
/> />
<span className="text-muted-foreground text-xs shrink-0">&deg;</span>
<button
type="button"
className="shrink-0 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-1.5 py-1 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => {
const newRotation = node.rotation - Math.PI / 2
handleUpdate({ rotation: newRotation })
}}
>
&minus;90
</button>
<button
type="button"
className="shrink-0 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-1.5 py-1 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => {
const newRotation = node.rotation + Math.PI / 2
handleUpdate({ rotation: newRotation })
}}
>
+90
</button>
</div>
</div> </div>
</PanelSection>
{/* Position */} <PanelSection title="Position">
<div className="space-y-2"> <SliderControl
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide"> label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
Position value={Math.round(node.position[0] * 100) / 100}
</label> onChange={(v) => {
<div className="grid grid-cols-3 gap-2">
{([0, 1, 2] as const).map((i) => (
<div key={i} className="space-y-1">
<label className="text-muted-foreground text-xs">{['X', 'Y', 'Z'][i]}</label>
<input
className="w-full rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1 text-foreground text-sm outline-none focus:ring-1 focus:ring-primary focus:border-primary"
onChange={(e) => {
const value = Number.parseFloat(e.target.value)
if (!Number.isNaN(value)) {
const pos = [...node.position] as [number, number, number] const pos = [...node.position] as [number, number, number]
pos[i] = value pos[0] = v
handleUpdate({ position: pos }) handleUpdate({ position: pos })
}
}} }}
step="0.5" min={-50}
type="number" max={50}
value={Math.round(node.position[i] * 100) / 100} precision={2}
step={0.1}
unit="m"
/> />
</div> <SliderControl
))} label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
</div> value={Math.round(node.position[1] * 100) / 100}
</div> onChange={(v) => {
</div> const pos = [...node.position] as [number, number, number]
</div> pos[1] = v
</div> handleUpdate({ position: pos })
}}
min={-50}
max={50}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[2] * 100) / 100}
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"
/>
</PanelSection>
</PanelWrapper>
) )
} }
+61 -135
View File
@@ -2,11 +2,14 @@
import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core' import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Edit, Plus, Trash2, X } from 'lucide-react' import { Edit, Plus, Trash2 } from 'lucide-react'
import Image from 'next/image'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import useEditor from '@/store/use-editor' import useEditor from '@/store/use-editor'
import { NumberInput } from '@/components/ui/primitives/number-input'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { ActionButton, ActionGroup } from '../controls/action-button'
export function SlabPanel() { export function SlabPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -16,7 +19,6 @@ export function SlabPanel() {
const editingHole = useEditor((s) => s.editingHole) const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole) const setEditingHole = useEditor((s) => s.setEditingHole)
// Get the first selected node if it's a slab
const selectedId = selectedIds[0] const selectedId = selectedIds[0]
const node = selectedId const node = selectedId
? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined) ? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined)
@@ -35,14 +37,12 @@ export function SlabPanel() {
setEditingHole(null) setEditingHole(null)
}, [setSelection, setEditingHole]) }, [setSelection, setEditingHole])
// Clear hole editing state when slab is deselected
useEffect(() => { useEffect(() => {
if (!node) { if (!node) {
setEditingHole(null) setEditingHole(null)
} }
}, [node, setEditingHole]) }, [node, setEditingHole])
// Clear hole editing state on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
setEditingHole(null) setEditingHole(null)
@@ -52,7 +52,6 @@ export function SlabPanel() {
const handleAddHole = useCallback(() => { const handleAddHole = useCallback(() => {
if (!node || !selectedId) return if (!node || !selectedId) return
// Calculate centroid of the slab polygon
const polygon = node.polygon const polygon = node.polygon
let cx = 0 let cx = 0
let cz = 0 let cz = 0
@@ -63,7 +62,6 @@ export function SlabPanel() {
cx /= polygon.length cx /= polygon.length
cz /= polygon.length cz /= polygon.length
// Create a default small rectangular hole centered at the slab's centroid
const holeSize = 0.5 const holeSize = 0.5
const newHole: Array<[number, number]> = [ const newHole: Array<[number, number]> = [
[cx - holeSize, cz - holeSize], [cx - holeSize, cz - holeSize],
@@ -73,7 +71,6 @@ export function SlabPanel() {
] ]
const currentHoles = node?.holes || [] const currentHoles = node?.holes || []
handleUpdate({ holes: [...currentHoles, newHole] }) handleUpdate({ holes: [...currentHoles, newHole] })
// Enter edit mode for the new hole
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length }) setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole]) }, [node, selectedId, handleUpdate, setEditingHole])
@@ -98,10 +95,8 @@ export function SlabPanel() {
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole], [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
) )
// Only show if exactly one slab is selected
if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
// Calculate approximate area from polygon
const calculateArea = (polygon: Array<[number, number]>): number => { const calculateArea = (polygon: Array<[number, number]>): number => {
if (polygon.length < 3) return 0 if (polygon.length < 3) return 0
let area = 0 let area = 0
@@ -117,159 +112,82 @@ export function SlabPanel() {
const area = calculateArea(node.polygon) const area = calculateArea(node.polygon)
return ( return (
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-72 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md"> <PanelWrapper
{/* Header */} title={node.name || "Slab"}
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent"> icon="/icons/floor.png"
<div className="flex items-center gap-2 min-w-0"> onClose={handleClose}
<Image src="/icons/floor.png" alt="" width={16} height={16} className="shrink-0 object-contain" /> width={320}
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || "Slab"}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
onClick={handleClose}
> >
<X className="h-4 w-4" /> <PanelSection title="Elevation">
</button> <SliderControl
</div> label="Height"
{/* Content */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-4">
{/* Elevation */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Elevation
</label>
<div className="flex items-center gap-2">
<NumberInput
label="Elevation"
value={Math.round(node.elevation * 1000) / 1000} value={Math.round(node.elevation * 1000) / 1000}
onChange={(value) => { onChange={(v) => handleUpdate({ elevation: v })}
handleUpdate({ elevation: value }) min={-1}
}} max={1}
precision={3} precision={3}
className="flex-1" step={0.01}
unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<p className="text-xs text-muted-foreground">
Height offset from the level base (positive = raised, negative = sunken)
</p>
</div>
{/* Quick preset buttons */} <div className="mt-2 grid grid-cols-2 gap-1.5 px-1 pb-1">
<div className="space-y-2"> <ActionButton label="Sunken (-15cm)" onClick={() => handleUpdate({ elevation: -0.15 })} />
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide"> <ActionButton label="Ground (0m)" onClick={() => handleUpdate({ elevation: 0 })} />
Presets <ActionButton label="Raised (+5cm)" onClick={() => handleUpdate({ elevation: 0.05 })} />
</label> <ActionButton label="Step (+15cm)" onClick={() => handleUpdate({ elevation: 0.15 })} />
<div className="grid grid-cols-4 gap-2">
<button
type="button"
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => handleUpdate({ elevation: -0.15 })}
>
Sunken (-15cm)
</button>
<button
type="button"
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => handleUpdate({ elevation: 0 })}
>
Ground (0m)
</button>
<button
type="button"
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => handleUpdate({ elevation: 0.05 })}
>
Raised (5cm)
</button>
<button
type="button"
className="rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => handleUpdate({ elevation: 0.15 })}
>
Step (15cm)
</button>
</div>
</div> </div>
</PanelSection>
{/* Area info */} <PanelSection title="Info">
<div className="space-y-2"> <div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide"> <span>Area</span>
Area <span className="font-mono text-white">{area.toFixed(2)} m²</span>
</label>
<div className="rounded-lg border border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-3 py-2 text-sm text-foreground">
{area.toFixed(2)} m²
</div>
</div> </div>
</PanelSection>
{/* Holes */} <PanelSection title="Holes">
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Holes
</label>
{editingHole?.nodeId === selectedId ? (
<button
type="button"
className="flex items-center gap-1 rounded-md border border-green-500 bg-green-500/10 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium text-green-600 hover:bg-green-500/20 transition-colors cursor-pointer"
onClick={() => setEditingHole(null)}
>
<span>Done Editing</span>
</button>
) : (
<button
type="button"
className="flex items-center gap-1 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleAddHole}
>
<Plus className="h-3 w-3" />
<span>Add Hole</span>
</button>
)}
</div>
{node.holes && node.holes.length > 0 ? ( {node.holes && node.holes.length > 0 ? (
<div className="space-y-2"> <div className="flex flex-col gap-1 pb-2">
{node.holes.map((hole, index) => { {node.holes.map((hole, index) => {
const holeArea = calculateArea(hole) const holeArea = calculateArea(hole)
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
return ( return (
<div <div
key={index} key={index}
className={`flex items-center justify-between rounded-lg border px-3 py-2 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] transition-colors ${ className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
isEditing isEditing
? 'border-green-500 bg-green-500/10 ring-1 ring-green-500/20' ? 'border-primary/50 bg-primary/10'
: 'border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30' : 'border-transparent hover:bg-accent/30'
}`} }`}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${isEditing ? 'text-green-600' : ''}`}> <p className={`text-xs font-medium ${isEditing ? 'text-primary' : 'text-white'}`}>
Hole {index + 1} {isEditing && '(Editing)'} Hole {index + 1} {isEditing && '(Editing)'}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} vertices {holeArea.toFixed(2)} m² · {hole.length} pts
</p> </p>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{!isEditing && ( {isEditing ? (
<ActionButton
label="Done"
onClick={() => setEditingHole(null)}
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
/>
) : (
<> <>
<button <button
type="button" type="button"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer" 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)} onClick={() => handleEditHole(index)}
aria-label="Edit hole"
> >
<Edit className="h-3.5 w-3.5" /> <Edit className="h-3.5 w-3.5" />
</button> </button>
<button <button
type="button" type="button"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer" 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)} onClick={() => handleDeleteHole(index)}
aria-label="Delete hole"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
@@ -281,13 +199,21 @@ export function SlabPanel() {
})} })}
</div> </div>
) : ( ) : (
<p className="text-xs text-muted-foreground italic"> <div className="px-2 py-3 text-center text-xs text-muted-foreground">
No holes. Click "Add Hole" to create one. No holes
</p> </div>
)} )}
<div className="px-1 pt-1 pb-1">
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Hole"
onClick={handleAddHole}
className="w-full"
disabled={editingHole?.nodeId === selectedId}
/>
</div> </div>
</div> </PanelSection>
</div> </PanelWrapper>
</div>
) )
} }
+23 -49
View File
@@ -2,10 +2,11 @@
import { type AnyNode, type AnyNodeId, type WallNode, useScene } from '@pascal-app/core' import { type AnyNode, type AnyNodeId, type WallNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { X } from 'lucide-react'
import Image from 'next/image'
import { useCallback } from 'react' import { useCallback } from 'react'
import { NumberInput } from '@/components/ui/primitives/number-input'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
export function WallPanel() { export function WallPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -41,68 +42,41 @@ export function WallPanel() {
const thickness = node.thickness ?? 0.1 const thickness = node.thickness ?? 0.1
return ( return (
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-64 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md"> <PanelWrapper
{/* Header */} title={node.name || "Wall"}
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent"> icon="/icons/wall.png"
<div className="flex items-center gap-2 min-w-0"> onClose={handleClose}
<Image src="/icons/wall.png" alt="" width={16} height={16} className="shrink-0 object-contain" /> width={280}
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || "Wall"}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
onClick={handleClose}
> >
<X className="h-4 w-4" /> <PanelSection title="Dimensions">
</button> <SliderControl
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Dimensions */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Dimensions
</label>
<div className="flex items-center gap-1.5">
<NumberInput
label="Height" label="Height"
value={Math.round(height * 100) / 100} value={Math.round(height * 100) / 100}
onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })} onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })}
min={0.1} min={0.1}
max={6}
precision={2} precision={2}
step={0.1} step={0.1}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> <SliderControl
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Thickness" label="Thickness"
value={Math.round(thickness * 1000) / 1000} value={Math.round(thickness * 1000) / 1000}
onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })} onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })}
min={0.05} min={0.05}
max={1}
precision={3} precision={3}
step={0.01} step={0.01}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> </PanelSection>
</div>
</div>
{/* Info */} <PanelSection title="Info">
<div className="space-y-2"> <div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide"> <span>Length</span>
Info <span className="font-mono text-white">{length.toFixed(2)} m</span>
</label>
<div className="rounded-lg border border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30 shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-3 py-2 text-sm font-mono text-foreground">
Length: {length.toFixed(2)} m
</div>
</div>
</div>
</div> </div>
</PanelSection>
</PanelWrapper>
) )
} }
+100 -167
View File
@@ -2,13 +2,17 @@
import { type AnyNode, type AnyNodeId, WindowNode, useScene } from '@pascal-app/core' import { type AnyNode, type AnyNodeId, WindowNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Copy, FlipHorizontal2, Move, Trash2, X } from 'lucide-react' import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import Image from 'next/image'
import { useCallback } from 'react' import { useCallback } from 'react'
import { sfxEmitter } from '@/lib/sfx-bus' import { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor' import useEditor from '@/store/use-editor'
import { NumberInput } from '@/components/ui/primitives/number-input'
import { Switch } from '@/components/ui/primitives/switch' 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 { ActionButton, ActionGroup } from '../controls/action-button'
export function WindowPanel() { export function WindowPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -92,7 +96,6 @@ export function WindowPanel() {
const numCols = node.columnRatios.length const numCols = node.columnRatios.length
const numRows = node.rowRatios.length const numRows = node.rowRatios.length
// Normalized ratios (always sum to 1 for display)
const colSum = node.columnRatios.reduce((a, b) => a + b, 0) const colSum = node.columnRatios.reduce((a, b) => a + b, 0)
const rowSum = node.rowRatios.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 normCols = node.columnRatios.map(r => r / colSum)
@@ -125,127 +128,91 @@ export function WindowPanel() {
} }
return ( return (
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-82 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md"> <PanelWrapper
{/* Header */} title={node.name || "Window"}
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent"> icon="/icons/window.png"
<div className="flex items-center gap-2 min-w-0"> onClose={handleClose}
<Image src="/icons/window.png" alt="" width={16} height={16} className="shrink-0 object-contain" /> width={320}
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || "Window"}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground cursor-pointer"
onClick={handleClose}
> >
<X className="h-4 w-4" /> <PanelSection title="Position">
</button> <SliderControl
</div> label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Position */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Position
</label>
<div className="grid grid-cols-2 gap-2">
<NumberInput
label="X"
value={Math.round(node.position[0] * 100) / 100} value={Math.round(node.position[0] * 100) / 100}
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })} onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
min={-10}
max={10}
precision={2} precision={2}
step={0.1}
unit="m"
/> />
<NumberInput <SliderControl
label="Y" label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[1] * 100) / 100} value={Math.round(node.position[1] * 100) / 100}
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })} onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
min={-10}
max={10}
precision={2} precision={2}
step={0.1}
unit="m"
/>
<div className="pt-2 pb-1 px-1">
<ActionButton
icon={<FlipHorizontal2 className="h-4 w-4" />}
label="Flip Side"
onClick={handleFlip}
className="w-full"
/> />
</div> </div>
<button </PanelSection>
type="button"
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleFlip}
>
<FlipHorizontal2 className="h-3.5 w-3.5" />
Flip Side
</button>
</div>
{/* Dimensions */} <PanelSection title="Dimensions">
<div className="space-y-2"> <SliderControl
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Dimensions
</label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5">
<NumberInput
label="Width" label="Width"
value={Math.round(node.width * 100) / 100} value={Math.round(node.width * 100) / 100}
onChange={(v) => handleUpdate({ width: v })} onChange={(v) => handleUpdate({ width: v })}
min={0.2} min={0.2}
max={5}
precision={2} precision={2}
className="flex-1" step={0.1}
unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> <SliderControl
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Height" label="Height"
value={Math.round(node.height * 100) / 100} value={Math.round(node.height * 100) / 100}
onChange={(v) => handleUpdate({ height: v })} onChange={(v) => handleUpdate({ height: v })}
min={0.2} min={0.2}
max={5}
precision={2} precision={2}
className="flex-1" step={0.1}
unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> </PanelSection>
</div>
</div>
</div>
{/* Frame */} <PanelSection title="Frame">
<div className="space-y-2"> <SliderControl
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Frame
</label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5">
<NumberInput
label="Thickness" label="Thickness"
value={Math.round(node.frameThickness * 1000) / 1000} value={Math.round(node.frameThickness * 1000) / 1000}
onChange={(v) => handleUpdate({ frameThickness: v })} onChange={(v) => handleUpdate({ frameThickness: v })}
min={0.01} min={0.01}
max={0.2}
precision={3} precision={3}
step={0.01} step={0.01}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> <SliderControl
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Depth" label="Depth"
value={Math.round(node.frameDepth * 1000) / 1000} value={Math.round(node.frameDepth * 1000) / 1000}
onChange={(v) => handleUpdate({ frameDepth: v })} onChange={(v) => handleUpdate({ frameDepth: v })}
min={0.01} min={0.01}
max={0.3}
precision={3} precision={3}
step={0.01} step={0.01}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> </PanelSection>
</div>
</div>
</div>
{/* Grid */} <PanelSection title="Grid">
<div className="space-y-2"> <SliderControl
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Grid
</label>
<div className="grid grid-cols-2 gap-2">
<NumberInput
label="Columns" label="Columns"
value={numCols} value={numCols}
onChange={(v) => { onChange={(v) => {
@@ -257,7 +224,7 @@ export function WindowPanel() {
precision={0} precision={0}
step={1} step={1}
/> />
<NumberInput <SliderControl
label="Rows" label="Rows"
value={numRows} value={numRows}
onChange={(v) => { onChange={(v) => {
@@ -269,15 +236,13 @@ export function WindowPanel() {
precision={0} precision={0}
step={1} step={1}
/> />
</div>
{/* Column ratios */}
{numCols > 1 && ( {numCols > 1 && (
<div className="space-y-1"> <div className="mt-2 flex flex-col gap-1">
<span className="text-muted-foreground text-xs">Column widths</span> <div className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Col Widths</div>
{normCols.map((ratio, i) => ( {normCols.map((ratio, i) => (
<div key={i} className="flex items-center gap-1.5"> <SliderControl
<NumberInput key={`c-${i}`}
label={`C${i + 1}`} label={`C${i + 1}`}
value={Math.round(ratio * 100 * 10) / 10} value={Math.round(ratio * 100 * 10) / 10}
onChange={(v) => setColumnRatio(i, v / 100)} onChange={(v) => setColumnRatio(i, v / 100)}
@@ -285,33 +250,30 @@ export function WindowPanel() {
max={95} max={95}
precision={1} precision={1}
step={1} step={1}
className="flex-1" unit="%"
/> />
<span className="text-muted-foreground text-xs shrink-0">%</span>
</div>
))} ))}
<div className="flex items-center gap-1.5"> <div className="mt-1 border-t border-border/50 pt-1">
<NumberInput <SliderControl
label="Col divider" label="Divider"
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000} value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
onChange={(v) => handleUpdate({ columnDividerThickness: v })} onChange={(v) => handleUpdate({ columnDividerThickness: v })}
min={0.005} min={0.005}
max={0.1}
precision={3} precision={3}
step={0.01} step={0.01}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div> </div>
</div> </div>
)} )}
{/* Row ratios */}
{numRows > 1 && ( {numRows > 1 && (
<div className="space-y-1"> <div className="mt-2 flex flex-col gap-1">
<span className="text-muted-foreground text-xs">Row heights</span> <div className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Row Heights</div>
{normRows.map((ratio, i) => ( {normRows.map((ratio, i) => (
<div key={i} className="flex items-center gap-1.5"> <SliderControl
<NumberInput key={`r-${i}`}
label={`R${i + 1}`} label={`R${i + 1}`}
value={Math.round(ratio * 100 * 10) / 10} value={Math.round(ratio * 100 * 10) / 10}
onChange={(v) => setRowRatio(i, v / 100)} onChange={(v) => setRowRatio(i, v / 100)}
@@ -319,98 +281,69 @@ export function WindowPanel() {
max={95} max={95}
precision={1} precision={1}
step={1} step={1}
className="flex-1" unit="%"
/> />
<span className="text-muted-foreground text-xs shrink-0">%</span>
</div>
))} ))}
<div className="flex items-center gap-1.5"> <div className="mt-1 border-t border-border/50 pt-1">
<NumberInput <SliderControl
label="Row divider" label="Divider"
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000} value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
onChange={(v) => handleUpdate({ rowDividerThickness: v })} onChange={(v) => handleUpdate({ rowDividerThickness: v })}
min={0.005} min={0.005}
max={0.1}
precision={3} precision={3}
step={0.01} step={0.01}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div> </div>
</div> </div>
)} )}
</div> </PanelSection>
{/* Sill */} <PanelSection title="Sill">
<div className="space-y-2"> <ToggleControl
<div className="flex items-center justify-between"> label="Enable Sill"
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Sill
</label>
<Switch
checked={node.sill} checked={node.sill}
onCheckedChange={(checked) => handleUpdate({ sill: checked })} onChange={(checked) => handleUpdate({ sill: checked })}
/> />
</div>
{node.sill && ( {node.sill && (
<div className="grid grid-cols-2 gap-2"> <div className="mt-1 flex flex-col gap-1">
<div className="flex items-center gap-1.5"> <SliderControl
<NumberInput
label="Depth" label="Depth"
value={Math.round(node.sillDepth * 1000) / 1000} value={Math.round(node.sillDepth * 1000) / 1000}
onChange={(v) => handleUpdate({ sillDepth: v })} onChange={(v) => handleUpdate({ sillDepth: v })}
min={0.01} min={0.01}
max={0.5}
precision={3} precision={3}
step={0.01} step={0.01}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> <SliderControl
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Thickness" label="Thickness"
value={Math.round(node.sillThickness * 1000) / 1000} value={Math.round(node.sillThickness * 1000) / 1000}
onChange={(v) => handleUpdate({ sillThickness: v })} onChange={(v) => handleUpdate({ sillThickness: v })}
min={0.005} min={0.005}
max={0.2}
precision={3} precision={3}
step={0.01} step={0.01}
className="flex-1" unit="m"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div> </div>
)} )}
</div> </PanelSection>
</div>
{/* Action Buttons */} <PanelSection title="Actions">
<div className="border-t border-border/50 p-3 bg-white/50 dark:bg-transparent"> <ActionGroup>
<div className="flex gap-2"> <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<button <ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
type="button" <ActionButton
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer" icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
onClick={handleMove} label="Delete"
>
<Move className="h-3.5 w-3.5" />
<span>Move</span>
</button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleDuplicate}
>
<Copy className="h-3.5 w-3.5" />
<span>Duplicate</span>
</button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200/60 dark:border-border/50 bg-white dark:bg-background shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] px-2 py-1.5 text-xs font-medium font-barlow text-foreground hover:bg-black/5 dark:hover:bg-white/10 transition-colors cursor-pointer"
onClick={handleDelete} onClick={handleDelete}
> className="hover:bg-red-500/20"
<Trash2 className="h-3.5 w-3.5" /> />
<span>Delete</span> </ActionGroup>
</button> </PanelSection>
</div> </PanelWrapper>
</div>
</div>
) )
} }
@@ -131,7 +131,7 @@ export function AppSidebar() {
{mounted && ( {mounted && (
<button <button
className="shrink-0 flex items-center bg-accent/50 rounded-full p-1 border border-border/50 cursor-pointer" className="shrink-0 flex items-center bg-black/20 rounded-full p-1 border border-border/50 cursor-pointer"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
type="button" type="button"
aria-label="Toggle theme" aria-label="Toggle theme"
@@ -139,7 +139,7 @@ export function AppSidebar() {
<div className="relative flex"> <div className="relative flex">
{/* Sliding Background */} {/* Sliding Background */}
<motion.div <motion.div
className="absolute inset-0 bg-white shadow-sm rounded-full dark:bg-white/20" className="absolute inset-0 bg-[#3A3A3C] shadow-sm rounded-full"
initial={false} initial={false}
animate={{ animate={{
x: theme === "light" ? "100%" : "0%", x: theme === "light" ? "100%" : "0%",
@@ -779,13 +779,13 @@ function LayerToggle() {
phase === "structure" && structureLayer === "zones" ? "zones" : "none"; phase === "structure" && structureLayer === "zones" ? "zones" : "none";
return ( return (
<div className="flex items-center p-1 bg-accent/20 gap-1 border-b border-border/50 relative"> <div className="flex items-center p-1 bg-[#2C2C2E] gap-1 border-b border-border/50 relative">
<button <button
className={cn( className={cn(
"relative flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer", "relative flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
activeTab === "structure" activeTab === "structure"
? "text-foreground" ? "text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50" : "text-muted-foreground hover:text-foreground hover:bg-white/5"
)} )}
onClick={() => { onClick={() => {
setPhase("structure"); setPhase("structure");
@@ -795,7 +795,7 @@ function LayerToggle() {
{activeTab === "structure" && ( {activeTab === "structure" && (
<motion.div <motion.div
layoutId="layerToggleActiveBg" layoutId="layerToggleActiveBg"
className="absolute inset-0 bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 rounded-md" className="absolute inset-0 bg-[#3e3e3e] shadow-sm ring-1 ring-border/50 rounded-md"
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }} transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
/> />
)} )}
@@ -819,7 +819,7 @@ function LayerToggle() {
"relative flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer", "relative flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
activeTab === "furnish" activeTab === "furnish"
? "text-foreground" ? "text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50" : "text-muted-foreground hover:text-foreground hover:bg-white/5"
)} )}
onClick={() => { onClick={() => {
setPhase("furnish"); setPhase("furnish");
@@ -828,7 +828,7 @@ function LayerToggle() {
{activeTab === "furnish" && ( {activeTab === "furnish" && (
<motion.div <motion.div
layoutId="layerToggleActiveBg" layoutId="layerToggleActiveBg"
className="absolute inset-0 bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 rounded-md" className="absolute inset-0 bg-[#3e3e3e] shadow-sm ring-1 ring-border/50 rounded-md"
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }} transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
/> />
)} )}
@@ -852,7 +852,7 @@ function LayerToggle() {
"relative flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer", "relative flex-1 flex flex-col items-center justify-center py-2 rounded-md text-[10px] font-medium transition-all duration-200 cursor-pointer",
activeTab === "zones" activeTab === "zones"
? "text-foreground" ? "text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-white/50 dark:hover:bg-accent/50" : "text-muted-foreground hover:text-foreground hover:bg-white/5"
)} )}
onClick={() => { onClick={() => {
setPhase("structure"); setPhase("structure");
@@ -862,7 +862,7 @@ function LayerToggle() {
{activeTab === "zones" && ( {activeTab === "zones" && (
<motion.div <motion.div
layoutId="layerToggleActiveBg" layoutId="layerToggleActiveBg"
className="absolute inset-0 bg-white dark:bg-background shadow-sm ring-1 ring-black/5 dark:ring-white/10 rounded-md" className="absolute inset-0 bg-[#3e3e3e] shadow-sm ring-1 ring-border/50 rounded-md"
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }} transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
/> />
)} )}