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>
)
}
+105 -173
View File
@@ -2,11 +2,14 @@
import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Edit, Plus, Trash2, X } from 'lucide-react'
import Image from 'next/image'
import { Edit, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react'
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() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -16,7 +19,6 @@ export function CeilingPanel() {
const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole)
// Get the first selected node if it's a ceiling
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as CeilingNode | undefined)
@@ -35,14 +37,12 @@ export function CeilingPanel() {
setEditingHole(null)
}, [setSelection, setEditingHole])
// Clear hole editing state when ceiling is deselected
useEffect(() => {
if (!node) {
setEditingHole(null)
}
}, [node, setEditingHole])
// Clear hole editing state on unmount
useEffect(() => {
return () => {
setEditingHole(null)
@@ -52,7 +52,6 @@ export function CeilingPanel() {
const handleAddHole = useCallback(() => {
if (!node || !selectedId) return
// Calculate centroid of the ceiling polygon
const polygon = node.polygon
let cx = 0
let cz = 0
@@ -63,7 +62,6 @@ export function CeilingPanel() {
cx /= polygon.length
cz /= polygon.length
// Create a default small rectangular hole centered at the ceiling's centroid
const holeSize = 0.5
const newHole: Array<[number, number]> = [
[cx - holeSize, cz - holeSize],
@@ -73,7 +71,6 @@ export function CeilingPanel() {
]
const currentHoles = node?.holes || []
handleUpdate({ holes: [...currentHoles, newHole] })
// Enter edit mode for the new hole
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole])
@@ -98,10 +95,8 @@ export function CeilingPanel() {
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
)
// Only show if exactly one ceiling is selected
if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
// Calculate approximate area from polygon
const calculateArea = (polygon: Array<[number, number]>): number => {
if (polygon.length < 3) return 0
let area = 0
@@ -117,170 +112,107 @@ export function CeilingPanel() {
const area = calculateArea(node.polygon)
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">
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex items-center gap-2 min-w-0">
<Image src="/icons/ceiling.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || "Ceiling"}
</h2>
<PanelWrapper
title={node.name || "Ceiling"}
icon="/icons/ceiling.png"
onClose={handleClose}
width={320}
>
<PanelSection title="Height">
<SliderControl
label="Height"
value={Math.round(node.height * 1000) / 1000}
onChange={(v) => handleUpdate({ height: v })}
min={0}
max={6}
precision={3}
step={0.01}
unit="m"
/>
<div className="mt-2 grid grid-cols-3 gap-1.5 px-1 pb-1">
<ActionButton label="Low (2.4m)" onClick={() => handleUpdate({ height: 2.4 })} />
<ActionButton label="Standard (2.5m)" onClick={() => handleUpdate({ height: 2.5 })} />
<ActionButton label="High (3.0m)" onClick={() => handleUpdate({ height: 3.0 })} />
</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" />
</button>
</div>
</PanelSection>
{/* 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"
value={Math.round(node.height * 1000) / 1000}
onChange={(value) => {
handleUpdate({ height: value })
}}
precision={3}
className="flex-1"
/>
<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="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Presets
</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>
{/* Area info */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Area
</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>
{/* 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 ? (
<div className="space-y-2">
{node.holes.map((hole, index) => {
const holeArea = calculateArea(hole)
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
return (
<div
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 ${
isEditing
? 'border-green-500 bg-green-500/10 ring-1 ring-green-500/20'
: 'border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30'
}`}
>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${isEditing ? 'text-green-600' : ''}`}>
Hole {index + 1} {isEditing && '(Editing)'}
</p>
<p className="text-xs text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} vertices
</p>
</div>
<div className="flex items-center gap-1">
{!isEditing && (
<>
<button
type="button"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
onClick={() => handleEditHole(index)}
aria-label="Edit hole"
>
<Edit className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
onClick={() => handleDeleteHole(index)}
aria-label="Delete hole"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
)
})}
</div>
) : (
<p className="text-xs text-muted-foreground italic">
No holes. Click "Add Hole" to create one.
</p>
)}
</div>
<PanelSection title="Info">
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
<span>Area</span>
<span className="font-mono text-white">{area.toFixed(2)} m²</span>
</div>
</div>
</div>
</PanelSection>
<PanelSection title="Holes">
{node.holes && node.holes.length > 0 ? (
<div className="flex flex-col gap-1 pb-2">
{node.holes.map((hole, index) => {
const holeArea = calculateArea(hole)
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
return (
<div
key={index}
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
isEditing
? 'border-primary/50 bg-primary/10'
: 'border-transparent hover:bg-accent/30'
}`}
>
<div className="flex-1 min-w-0">
<p className={`text-xs font-medium ${isEditing ? 'text-primary' : 'text-white'}`}>
Hole {index + 1} {isEditing && '(Editing)'}
</p>
<p className="text-[10px] text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} pts
</p>
</div>
<div className="flex items-center gap-1">
{isEditing ? (
<ActionButton
label="Done"
onClick={() => setEditingHole(null)}
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
/>
) : (
<>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground"
onClick={() => handleEditHole(index)}
>
<Edit className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300"
onClick={() => handleDeleteHole(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
)
})}
</div>
) : (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
No holes
</div>
)}
<div className="px-1 pt-1 pb-1">
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Hole"
onClick={handleAddHole}
className="w-full"
disabled={editingHole?.nodeId === selectedId}
/>
</div>
</PanelSection>
</PanelWrapper>
)
}
+348 -486
View File
@@ -2,13 +2,18 @@
import { type AnyNode, type AnyNodeId, DoorNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, FlipHorizontal2, Move, Trash2, X } from 'lucide-react'
import Image from 'next/image'
import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '@/lib/sfx-bus'
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() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -136,509 +141,366 @@ export function DoorPanel() {
const normHeights = node.segments.map(seg => seg.heightRatio / hSum)
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)]">
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex items-center gap-2 min-w-0">
<Image src="/icons/door.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || "Door"}
</h2>
<PanelWrapper
title={node.name || "Door"}
icon="/icons/door.png"
onClose={handleClose}
width={320}
>
<PanelSection title="Position">
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">wall</sub></>}
value={Math.round(node.position[0] * 100) / 100}
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
min={-10}
max={10}
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>
<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" />
</button>
</div>
</PanelSection>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<PanelSection title="Dimensions">
<SliderControl
label="Width"
value={Math.round(node.width * 100) / 100}
onChange={(v) => handleUpdate({ width: v })}
min={0.5}
max={3}
precision={2}
step={0.05}
unit="m"
/>
<SliderControl
label="Height"
value={Math.round(node.height * 100) / 100}
onChange={(v) => handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })}
min={1.0}
max={4}
precision={2}
step={0.05}
unit="m"
/>
</PanelSection>
{/* 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}
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
<PanelSection title="Frame">
<SliderControl
label="Thickness"
value={Math.round(node.frameThickness * 1000) / 1000}
onChange={(v) => handleUpdate({ frameThickness: v })}
min={0.01}
max={0.2}
precision={3}
step={0.01}
unit="m"
/>
<SliderControl
label="Depth"
value={Math.round(node.frameDepth * 1000) / 1000}
onChange={(v) => handleUpdate({ frameDepth: v })}
min={0.01}
max={0.3}
precision={3}
step={0.01}
unit="m"
/>
</PanelSection>
<PanelSection title="Content Padding">
<SliderControl
label="Horizontal"
value={Math.round(node.contentPadding[0] * 1000) / 1000}
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
min={0}
max={0.2}
precision={3}
step={0.005}
unit="m"
/>
<SliderControl
label="Vertical"
value={Math.round(node.contentPadding[1] * 1000) / 1000}
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
min={0}
max={0.2}
precision={3}
step={0.005}
unit="m"
/>
</PanelSection>
<PanelSection title="Swing">
<div className="flex flex-col gap-2 px-1 pb-1">
<div className="space-y-1">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Hinges Side</span>
<SegmentedControl
value={node.hingesSide}
onChange={(v) => handleUpdate({ hingesSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
/>
</div>
<div className="space-y-1">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Direction</span>
<SegmentedControl
value={node.swingDirection}
onChange={(v) => handleUpdate({ swingDirection: v })}
options={[
{ label: 'Inward', value: 'inward' },
{ label: 'Outward', value: 'outward' },
]}
/>
</div>
</div>
</PanelSection>
<PanelSection title="Threshold">
<ToggleControl
label="Enable Threshold"
checked={node.threshold}
onChange={(checked) => handleUpdate({ threshold: checked })}
/>
{node.threshold && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Height"
value={Math.round(node.thresholdHeight * 1000) / 1000}
onChange={(v) => handleUpdate({ thresholdHeight: v })}
min={0.005}
max={0.1}
precision={3}
step={0.005}
unit="m"
/>
</div>
)}
</PanelSection>
<PanelSection title="Handle">
<ToggleControl
label="Enable Handle"
checked={node.handle}
onChange={(checked) => handleUpdate({ handle: checked })}
/>
{node.handle && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Height"
value={Math.round(node.handleHeight * 100) / 100}
onChange={(v) => handleUpdate({ handleHeight: v })}
min={0.5}
max={node.height - 0.1}
precision={2}
step={0.05}
unit="m"
/>
</div>
<button
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 */}
<div className="space-y-2">
<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"
value={Math.round(node.width * 100) / 100}
onChange={(v) => handleUpdate({ width: v })}
min={0.5}
precision={2}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Height"
value={Math.round(node.height * 100) / 100}
onChange={(v) => handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })}
min={1.0}
precision={2}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
</div>
{/* Frame */}
<div className="space-y-2">
<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"
value={Math.round(node.frameThickness * 1000) / 1000}
onChange={(v) => handleUpdate({ frameThickness: v })}
min={0.01}
precision={3}
step={0.01}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Depth"
value={Math.round(node.frameDepth * 1000) / 1000}
onChange={(v) => handleUpdate({ frameDepth: v })}
min={0.01}
precision={3}
step={0.01}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
</div>
{/* Content Padding */}
<div className="space-y-2">
<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"
value={Math.round(node.contentPadding[0] * 1000) / 1000}
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
min={0}
precision={3}
step={0.005}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Vertical"
value={Math.round(node.contentPadding[1] * 1000) / 1000}
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
min={0}
precision={3}
step={0.005}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
</div>
{/* Swing */}
<div className="space-y-2">
<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">
<span className="text-xs text-muted-foreground">Hinges</span>
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg">
{(['left', 'right'] as const).map((side) => (
<button
key={side}
type="button"
onClick={() => handleUpdate({ hingesSide: side })}
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 className="space-y-1">
<span className="text-xs text-muted-foreground">Direction</span>
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg">
{(['inward', 'outward'] as const).map((dir) => (
<button
key={dir}
type="button"
onClick={() => handleUpdate({ swingDirection: dir })}
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>
{/* Threshold */}
<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">
Threshold
</label>
<Switch
checked={node.threshold}
onCheckedChange={(checked) => handleUpdate({ threshold: checked })}
/>
</div>
{node.threshold && (
<div className="flex items-center gap-1.5">
<NumberInput
label="Height"
value={Math.round(node.thresholdHeight * 1000) / 1000}
onChange={(v) => handleUpdate({ thresholdHeight: v })}
min={0.005}
precision={3}
step={0.005}
className="flex-1"
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Handle Side</span>
<SegmentedControl
value={node.handleSide}
onChange={(v) => handleUpdate({ handleSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
)}
</div>
</div>
)}
</PanelSection>
{/* Handle */}
<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">
Handle
</label>
<Switch
checked={node.handle}
onCheckedChange={(checked) => handleUpdate({ handle: checked })}
<PanelSection title="Hardware">
<ToggleControl
label="Door Closer"
checked={node.doorCloser}
onChange={(checked) => handleUpdate({ doorCloser: checked })}
/>
<ToggleControl
label="Panic Bar"
checked={node.panicBar}
onChange={(checked) => handleUpdate({ panicBar: checked })}
/>
{node.panicBar && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Bar Height"
value={Math.round(node.panicBarHeight * 100) / 100}
onChange={(v) => handleUpdate({ panicBarHeight: v })}
min={0.5}
max={node.height - 0.1}
precision={2}
step={0.05}
unit="m"
/>
</div>
{node.handle && (
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5">
<NumberInput
label="Height"
value={Math.round(node.handleHeight * 100) / 100}
onChange={(v) => handleUpdate({ handleHeight: v })}
min={0.5}
max={node.height - 0.1}
precision={2}
step={0.05}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
)}
</PanelSection>
<PanelSection title="Segments">
{node.segments.map((seg, i) => {
const numCols = seg.columnRatios.length
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
const normCols = seg.columnRatios.map(r => r / colSum)
return (
<div key={i} className="mb-2 flex flex-col gap-1">
<div className="flex items-center justify-between pb-1">
<span className="text-xs font-medium text-white/80">Segment {i + 1}</span>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Side</span>
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg">
{(['left', 'right'] as const).map((side) => (
<button
key={side}
type="button"
onClick={() => handleUpdate({ handleSide: side })}
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>
<SegmentedControl
value={seg.type}
onChange={(t) => {
const updated = node.segments.map((s, idx) => idx === i ? { ...s, type: t } : s)
handleUpdate({ segments: updated })
}}
options={[
{ label: 'Panel', value: 'panel' },
{ label: 'Glass', value: 'glass' },
{ label: 'Empty', value: 'empty' },
]}
/>
<SliderControl
label="Height"
value={Math.round(normHeights[i]! * 100 * 10) / 10}
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
min={5}
max={95}
precision={1}
step={1}
unit="%"
/>
<SliderControl
label="Columns"
value={numCols}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
)
handleUpdate({ segments: updated })
}}
min={1}
max={8}
precision={0}
step={1}
/>
{numCols > 1 && (
<div className="mt-1 border-t border-border/50 pt-1">
{normCols.map((ratio, ci) => (
<SliderControl
key={`c-${ci}`}
label={`C${ci + 1}`}
value={Math.round(ratio * 100 * 10) / 10}
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
min={5}
max={95}
precision={1}
step={1}
unit="%"
/>
))}
</div>
</div>
</div>
)}
</div>
{/* Hardware */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
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}
onCheckedChange={(checked) => handleUpdate({ doorCloser: checked })}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-foreground">Panic Bar</span>
<Switch
checked={node.panicBar}
onCheckedChange={(checked) => handleUpdate({ panicBar: checked })}
/>
</div>
{node.panicBar && (
<div className="flex items-center gap-1.5">
<NumberInput
label="Bar height"
value={Math.round(node.panicBarHeight * 100) / 100}
onChange={(v) => handleUpdate({ panicBarHeight: v })}
min={0.5}
max={node.height - 0.1}
precision={2}
step={0.05}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
)}
</div>
</div>
{/* 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) => {
const numCols = seg.columnRatios.length
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
const normCols = seg.columnRatios.map(r => r / colSum)
return (
<div key={i} className="rounded border border-border p-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">Segment {i + 1}</span>
<div className="flex gap-1 p-1 bg-accent/50 rounded-lg">
{(['panel', 'glass', 'empty'] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, type: t } : s,
)
handleUpdate({ segments: updated })
}}
className={`rounded border px-1.5 py-0.5 text-xs cursor-pointer transition-colors ${
seg.type === t
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-accent'
}`}
>
{t}
</button>
))}
</div>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Height"
value={Math.round(normHeights[i]! * 100 * 10) / 10}
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
min={5}
max={95}
precision={1}
step={1}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">%</span>
</div>
{/* Columns */}
<div className="space-y-1">
<NumberInput
label="Columns"
value={numCols}
<SliderControl
label="Divider"
value={Math.round(seg.dividerThickness * 1000) / 1000}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
idx === i ? { ...s, dividerThickness: v } : s,
)
handleUpdate({ segments: updated })
}}
min={1}
max={8}
precision={0}
step={1}
min={0.005}
max={0.1}
precision={3}
step={0.005}
unit="m"
/>
{numCols > 1 && (
<div className="space-y-1 pl-1">
{normCols.map((ratio, ci) => (
<div key={ci} className="flex items-center gap-1.5">
<NumberInput
label={`C${ci + 1}`}
value={Math.round(ratio * 100 * 10) / 10}
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
min={5}
max={95}
precision={1}
step={1}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">%</span>
</div>
))}
<div className="flex items-center gap-1.5">
<NumberInput
label="Divider"
value={Math.round(seg.dividerThickness * 1000) / 1000}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, dividerThickness: v } : s,
)
handleUpdate({ segments: updated })
}}
min={0.005}
precision={3}
step={0.005}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
)}
</div>
{seg.type === 'panel' && (
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5">
<NumberInput
label="Inset"
value={Math.round(seg.panelInset * 1000) / 1000}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelInset: v } : s,
)
handleUpdate({ segments: updated })
}}
min={0}
precision={3}
step={0.005}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Depth"
value={Math.round(seg.panelDepth * 1000) / 1000}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelDepth: v } : s,
)
handleUpdate({ segments: updated })
}}
min={0}
precision={3}
step={0.005}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
)}
</div>
)
})}
<div className="flex gap-2">
<button
type="button"
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={() => {
const updated = [
...node.segments,
{ type: 'panel' as const, heightRatio: 1, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 },
]
handleUpdate({ segments: updated })
}}
>
+ Add segment
</button>
{node.segments.length > 1 && (
<button
type="button"
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) })
}}
>
Remove last
</button>
)}
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="border-t border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex gap-2">
<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={handleMove}
>
<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}
>
<Trash2 className="h-3.5 w-3.5" />
<span>Delete</span>
</button>
{seg.type === 'panel' && (
<div className="mt-1 border-t border-border/50 pt-1">
<SliderControl
label="Inset"
value={Math.round(seg.panelInset * 1000) / 1000}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelInset: v } : s,
)
handleUpdate({ segments: updated })
}}
min={0}
max={0.1}
precision={3}
step={0.005}
unit="m"
/>
<SliderControl
label="Depth"
value={Math.round(seg.panelDepth * 1000) / 1000}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelDepth: v } : s,
)
handleUpdate({ segments: updated })
}}
min={0}
max={0.1}
precision={3}
step={0.005}
unit="m"
/>
</div>
)}
</div>
)
})}
<div className="flex gap-1.5 px-1 pt-1">
<ActionButton
label="+ Add Segment"
onClick={() => {
const updated = [
...node.segments,
{ type: 'panel' as const, heightRatio: 1, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 },
]
handleUpdate({ segments: updated })
}}
/>
{node.segments.length > 1 && (
<ActionButton
label="- Remove"
onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
className="text-white/60 hover:text-white"
/>
)}
</div>
</div>
</div>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
<ActionButton
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
label="Delete"
onClick={handleDelete}
className="hover:bg-red-500/20"
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
+164 -214
View File
@@ -2,12 +2,17 @@
import { getScaledDimensions, type AnyNode, ItemNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Link, Link2Off, Move, Trash2, X } from 'lucide-react'
import Image from 'next/image'
import { Copy, Link, Link2Off, Move, Trash2 } from 'lucide-react'
import { useCallback, useState } from 'react'
import useEditor from '@/store/use-editor'
import { NumberInput } from '@/components/ui/primitives/number-input'
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() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -17,7 +22,6 @@ export function ItemPanel() {
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
// Get the first selected node if it's an item
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined)
@@ -30,7 +34,6 @@ export function ItemPanel() {
if (!selectedId || !node) return
updateNode(selectedId as AnyNode['id'], updates)
// Mark parent wall as dirty if item is attached to wall
if (node.asset.attachTo === 'wall' && node.parentId) {
requestAnimationFrame(() => {
useScene.getState().dirtyNodes.add(node.parentId as AnyNode['id'])
@@ -48,7 +51,6 @@ export function ItemPanel() {
if (node) {
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
// Deselect so the panel closes
setSelection({ selectedIds: [] })
}
}, [node, setMovingNode, setSelection])
@@ -56,8 +58,6 @@ export function ItemPanel() {
const handleDuplicate = useCallback(() => {
if (!node) return
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({
position: [...node.position] as [number, number, number],
rotation: [...node.rotation] as [number, number, number],
@@ -78,221 +78,171 @@ export function ItemPanel() {
setSelection({ selectedIds: [] })
}, [selectedId, deleteNode, setSelection])
// Only show if exactly one item is selected
if (!node || node.type !== 'item' || selectedIds.length !== 1) return null
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">
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex items-center gap-2 min-w-0">
<Image
src={node.asset.thumbnail || '/icons/furniture.png'}
alt=""
width={16}
height={16}
className="shrink-0 object-contain"
<PanelWrapper
title={node.name || node.asset.name}
icon={node.asset.thumbnail || '/icons/furniture.png'}
onClose={handleClose}
width={300}
>
<PanelSection title="Position">
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[0] * 100) / 100}
onChange={(value) => handleUpdate({ position: [value, node.position[1], node.position[2]] })}
min={node.position[0] - 2}
max={node.position[0] + 2}
precision={2}
step={0.01}
unit="m"
/>
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[1] * 100) / 100}
onChange={(value) => handleUpdate({ position: [node.position[0], value, node.position[2]] })}
min={node.position[1] - 2}
max={node.position[1] + 2}
precision={2}
step={0.01}
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) => handleUpdate({ position: [node.position[0], node.position[1], value] })}
min={node.position[2] - 2}
max={node.position[2] + 2}
precision={2}
step={0.01}
unit="m"
/>
</PanelSection>
<PanelSection title="Rotation">
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
value={Math.round((node.rotation[1] * 180) / Math.PI)}
onChange={(degrees) => {
const radians = (degrees * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
min={Math.round((node.rotation[1] * 180) / Math.PI) - 45}
max={Math.round((node.rotation[1] * 180) / Math.PI) + 45}
precision={0}
step={1}
unit="°"
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
const currentDegrees = (node.rotation[1] * 180) / Math.PI
const radians = ((currentDegrees - 45) * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
/>
<ActionButton
label="+45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
const currentDegrees = (node.rotation[1] * 180) / Math.PI
const radians = ((currentDegrees + 45) * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
/>
<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" />
</button>
</div>
</PanelSection>
{/* 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}
onChange={(value) => {
handleUpdate({ position: [value, node.position[1], node.position[2]] })
}}
precision={2}
/>
<NumberInput
label="Y"
value={Math.round(node.position[1] * 100) / 100}
onChange={(value) => {
handleUpdate({ position: [node.position[0], value, node.position[2]] })
}}
precision={2}
/>
<NumberInput
label="Z"
value={Math.round(node.position[2] * 100) / 100}
onChange={(value) => {
handleUpdate({ position: [node.position[0], node.position[1], value] })
}}
precision={2}
/>
</div>
</div>
{/* Rotation */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Rotation
</label>
<div className="flex items-center gap-2">
<NumberInput
label="Y"
value={Math.round((node.rotation[1] * 180) / Math.PI)}
onChange={(degrees) => {
const radians = (degrees * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
precision={0}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">°</span>
</div>
<div className="flex gap-2">
<button
type="button"
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
const currentDegrees = (node.rotation[1] * 180) / Math.PI
const newDegrees = currentDegrees - 90
const radians = (newDegrees * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
>
-90°
</button>
<button
type="button"
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
const currentDegrees = (node.rotation[1] * 180) / Math.PI
const newDegrees = currentDegrees + 90
const radians = (newDegrees * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
>
+90°
</button>
</div>
</div>
{/* Scale */}
<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">
Scale
</label>
<button
type="button"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
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" />}
</button>
</div>
{uniformScale ? (
<NumberInput
label="XYZ"
value={Math.round(node.scale[0] * 100) / 100}
onChange={(value) => {
const v = Math.max(0.01, value)
handleUpdate({ scale: [v, v, v] })
}}
precision={2}
step={0.1}
/>
) : (
<div className="grid grid-cols-3 gap-2">
<NumberInput
label="X"
value={Math.round(node.scale[0] * 100) / 100}
onChange={(value) => {
handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] })
}}
precision={2}
step={0.1}
/>
<NumberInput
label="Y"
value={Math.round(node.scale[1] * 100) / 100}
onChange={(value) => {
handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] })
}}
precision={2}
step={0.1}
/>
<NumberInput
label="Z"
value={Math.round(node.scale[2] * 100) / 100}
onChange={(value) => {
handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] })
}}
precision={2}
step={0.1}
/>
</div>
<PanelSection title="Scale">
<div className="flex items-center justify-between px-2 pb-2">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Uniform Scale</span>
<button
type="button"
className={cn(
"flex h-6 w-6 items-center justify-center rounded-md transition-colors text-muted-foreground hover:text-foreground",
uniformScale ? "bg-[#3e3e3e]" : "bg-[#2C2C2E] hover:bg-[#3e3e3e]"
)}
</div>
{/* Dimensions (effective, read-only) */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
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)
return `${Math.round(w * 100) / 100}m × ${Math.round(h * 100) / 100}m × ${Math.round(d * 100) / 100}m`
})()}
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="border-t border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex gap-2">
<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={handleMove}
onClick={() => setUniformScale((v) => !v)}
>
<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}
>
<Trash2 className="h-3.5 w-3.5" />
<span>Delete</span>
{uniformScale ? <Link className="h-3.5 w-3.5" /> : <Link2Off className="h-3.5 w-3.5" />}
</button>
</div>
</div>
</div>
{uniformScale ? (
<SliderControl
label={<>XYZ<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale[0] * 100) / 100}
onChange={(value) => {
const v = Math.max(0.01, value)
handleUpdate({ scale: [v, v, v] })
}}
min={0.01}
max={10}
precision={2}
step={0.1}
/>
) : (
<>
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale[0] * 100) / 100}
onChange={(value) => handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] })}
min={0.01}
max={10}
precision={2}
step={0.1}
/>
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale[1] * 100) / 100}
onChange={(value) => handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] })}
min={0.01}
max={10}
precision={2}
step={0.1}
/>
<SliderControl
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale[2] * 100) / 100}
onChange={(value) => handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] })}
min={0.01}
max={10}
precision={2}
step={0.1}
/>
</>
)}
</PanelSection>
<PanelSection title="Info">
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
<span>Dimensions</span>
{(() => {
const [w, h, d] = getScaledDimensions(node)
return (
<span className="font-mono text-white">
{Math.round(w * 100) / 100}×{Math.round(h * 100) / 100}×{Math.round(d * 100) / 100}
</span>
)
})()}
</div>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
<ActionButton
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
label="Delete"
onClick={handleDelete}
className="hover:bg-red-500/20"
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
@@ -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'
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 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
@@ -35,132 +40,111 @@ export function ReferencePanel() {
const isScan = node.type === 'scan'
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">
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex items-center gap-2 min-w-0">
{isScan ? (
<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>
<PanelWrapper
title={node.name || (isScan ? '3D Scan' : 'Guide Image')}
icon={isScan ? undefined : undefined}
onClose={handleClose}
width={300}
>
<PanelSection title="Position">
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[0] * 100) / 100}
onChange={(value) => {
const pos = [...node.position] as [number, number, number]
pos[0] = value
handleUpdate({ position: pos })
}}
min={-50}
max={50}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
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>
<PanelSection title="Rotation">
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
value={Math.round((node.rotation[1] * 180) / Math.PI)}
onChange={(degrees) => {
const radians = (degrees * Math.PI) / 180
handleUpdate({
rotation: [node.rotation[0], radians, node.rotation[2]],
})
}}
min={-180}
max={180}
precision={0}
step={1}
unit="°"
/>
<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]] })}
/>
</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" />
</button>
</div>
</PanelSection>
{/* 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) => {
const pos = [...node.position] as [number, number, number]
pos[i] = value
handleUpdate({ position: pos })
}}
precision={2}
/>
))}
</div>
</div>
{/* Rotation Y */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Rotation
</label>
<div className="flex items-center gap-1.5">
<NumberInput
label="Y"
value={Math.round((node.rotation[1] * 180) / Math.PI)}
onChange={(degrees) => {
const radians = (degrees * Math.PI) / 180
handleUpdate({
rotation: [node.rotation[0], radians, node.rotation[2]],
})
}}
precision={0}
className="min-w-0 flex-1"
/>
<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>
{/* Scale */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Scale
</label>
<NumberInput
label="Scale"
value={Math.round(node.scale * 100) / 100}
onChange={(value) => {
if (value > 0) {
handleUpdate({ scale: value })
}
}}
min={0.01}
precision={2}
/>
</div>
{/* Opacity */}
<div className="space-y-2">
<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}
/>
</div>
</div>
</div>
</div>
<PanelSection title="Scale & Opacity">
<SliderControl
label={<>XYZ<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale * 100) / 100}
onChange={(value) => {
if (value > 0) {
handleUpdate({ scale: value })
}
}}
min={0.01}
max={10}
precision={2}
step={0.1}
/>
<SliderControl
label="Opacity"
value={node.opacity}
onChange={(v) => handleUpdate({ opacity: v })}
min={0}
max={100}
precision={0}
step={1}
unit="%"
/>
</PanelSection>
</PanelWrapper>
)
}
+130 -194
View File
@@ -2,17 +2,20 @@
import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { X } from 'lucide-react'
import Image from 'next/image'
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() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
// Get the first selected node if it's a roof
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined)
@@ -30,204 +33,137 @@ export function RoofPanel() {
setSelection({ selectedIds: [] })
}, [setSelection])
// Only show if exactly one roof is selected
if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
// Calculate total width for display
const totalWidth = node.leftWidth + node.rightWidth
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">
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex items-center gap-2 min-w-0">
<Image src="/icons/roof.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || "Roof"}
</h2>
<PanelWrapper
title={node.name || "Roof"}
icon="/icons/roof.png"
onClose={handleClose}
width={300}
>
<PanelSection title="Dimensions">
<SliderControl
label="Length"
value={Math.round(node.length * 100) / 100}
onChange={(v) => handleUpdate({ length: v })}
min={0.5}
max={20}
precision={2}
step={0.5}
unit="m"
/>
<SliderControl
label="Height"
value={Math.round(node.height * 100) / 100}
onChange={(v) => handleUpdate({ height: v })}
min={0.1}
max={10}
precision={2}
step={0.1}
unit="m"
/>
</PanelSection>
<PanelSection title="Slope Widths">
<div className="flex items-center justify-between px-2 pb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">
<span>Widths</span>
<span>Total: {totalWidth.toFixed(1)}m</span>
</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" />
</button>
</div>
<SliderControl
label="Left"
value={Math.round(node.leftWidth * 100) / 100}
onChange={(v) => handleUpdate({ leftWidth: v })}
min={0.1}
max={10}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label="Right"
value={Math.round(node.rightWidth * 100) / 100}
onChange={(v) => handleUpdate({ rightWidth: v })}
min={0.1}
max={10}
precision={2}
step={0.1}
unit="m"
/>
</PanelSection>
{/* 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}
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</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}
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
{/* Slope Widths */}
<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">
Slope Widths
</label>
<span className="text-muted-foreground text-xs">
Total: {totalWidth.toFixed(1)}m
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<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}
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</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}
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
</div>
</div>
{/* Rotation */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Rotation
</label>
<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
handleUpdate({ rotation: radians })
}
}}
step="1"
type="number"
value={Math.round((node.rotation * 180) / Math.PI)}
/>
<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>
{/* 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) => (
<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]
pos[i] = value
handleUpdate({ position: pos })
}
}}
step="0.5"
type="number"
value={Math.round(node.position[i] * 100) / 100}
/>
</div>
))}
</div>
</div>
<PanelSection title="Rotation">
<SliderControl
label={<>R<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
value={Math.round((node.rotation * 180) / Math.PI)}
onChange={(degrees) => {
const radians = (degrees * Math.PI) / 180
handleUpdate({ rotation: radians })
}}
min={-180}
max={180}
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 })}
/>
</div>
</div>
</div>
</PanelSection>
<PanelSection title="Position">
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[0] * 100) / 100}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[0] = v
handleUpdate({ position: pos })
}}
min={-50}
max={50}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[1] * 100) / 100}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[1] = v
handleUpdate({ position: pos })
}}
min={-50}
max={50}
precision={2}
step={0.1}
unit="m"
/>
<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>
)
}
+106 -180
View File
@@ -2,11 +2,14 @@
import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Edit, Plus, Trash2, X } from 'lucide-react'
import Image from 'next/image'
import { Edit, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react'
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() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -16,7 +19,6 @@ export function SlabPanel() {
const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole)
// Get the first selected node if it's a slab
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined)
@@ -35,14 +37,12 @@ export function SlabPanel() {
setEditingHole(null)
}, [setSelection, setEditingHole])
// Clear hole editing state when slab is deselected
useEffect(() => {
if (!node) {
setEditingHole(null)
}
}, [node, setEditingHole])
// Clear hole editing state on unmount
useEffect(() => {
return () => {
setEditingHole(null)
@@ -52,7 +52,6 @@ export function SlabPanel() {
const handleAddHole = useCallback(() => {
if (!node || !selectedId) return
// Calculate centroid of the slab polygon
const polygon = node.polygon
let cx = 0
let cz = 0
@@ -63,7 +62,6 @@ export function SlabPanel() {
cx /= polygon.length
cz /= polygon.length
// Create a default small rectangular hole centered at the slab's centroid
const holeSize = 0.5
const newHole: Array<[number, number]> = [
[cx - holeSize, cz - holeSize],
@@ -73,7 +71,6 @@ export function SlabPanel() {
]
const currentHoles = node?.holes || []
handleUpdate({ holes: [...currentHoles, newHole] })
// Enter edit mode for the new hole
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole])
@@ -98,10 +95,8 @@ export function SlabPanel() {
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
)
// Only show if exactly one slab is selected
if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
// Calculate approximate area from polygon
const calculateArea = (polygon: Array<[number, number]>): number => {
if (polygon.length < 3) return 0
let area = 0
@@ -117,177 +112,108 @@ export function SlabPanel() {
const area = calculateArea(node.polygon)
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">
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex items-center gap-2 min-w-0">
<Image src="/icons/floor.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || "Slab"}
</h2>
<PanelWrapper
title={node.name || "Slab"}
icon="/icons/floor.png"
onClose={handleClose}
width={320}
>
<PanelSection title="Elevation">
<SliderControl
label="Height"
value={Math.round(node.elevation * 1000) / 1000}
onChange={(v) => handleUpdate({ elevation: v })}
min={-1}
max={1}
precision={3}
step={0.01}
unit="m"
/>
<div className="mt-2 grid grid-cols-2 gap-1.5 px-1 pb-1">
<ActionButton label="Sunken (-15cm)" onClick={() => handleUpdate({ elevation: -0.15 })} />
<ActionButton label="Ground (0m)" onClick={() => handleUpdate({ elevation: 0 })} />
<ActionButton label="Raised (+5cm)" onClick={() => handleUpdate({ elevation: 0.05 })} />
<ActionButton label="Step (+15cm)" onClick={() => handleUpdate({ elevation: 0.15 })} />
</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" />
</button>
</div>
</PanelSection>
{/* 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}
onChange={(value) => {
handleUpdate({ elevation: value })
}}
precision={3}
className="flex-1"
/>
<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="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Presets
</label>
<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>
{/* Area info */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Area
</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>
{/* 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 ? (
<div className="space-y-2">
{node.holes.map((hole, index) => {
const holeArea = calculateArea(hole)
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
return (
<div
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 ${
isEditing
? 'border-green-500 bg-green-500/10 ring-1 ring-green-500/20'
: 'border-neutral-200/60 dark:border-border/50 bg-white/50 dark:bg-accent/30'
}`}
>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${isEditing ? 'text-green-600' : ''}`}>
Hole {index + 1} {isEditing && '(Editing)'}
</p>
<p className="text-xs text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} vertices
</p>
</div>
<div className="flex items-center gap-1">
{!isEditing && (
<>
<button
type="button"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
onClick={() => handleEditHole(index)}
aria-label="Edit hole"
>
<Edit className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
onClick={() => handleDeleteHole(index)}
aria-label="Delete hole"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
)
})}
</div>
) : (
<p className="text-xs text-muted-foreground italic">
No holes. Click "Add Hole" to create one.
</p>
)}
</div>
<PanelSection title="Info">
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
<span>Area</span>
<span className="font-mono text-white">{area.toFixed(2)} m²</span>
</div>
</div>
</div>
</PanelSection>
<PanelSection title="Holes">
{node.holes && node.holes.length > 0 ? (
<div className="flex flex-col gap-1 pb-2">
{node.holes.map((hole, index) => {
const holeArea = calculateArea(hole)
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
return (
<div
key={index}
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
isEditing
? 'border-primary/50 bg-primary/10'
: 'border-transparent hover:bg-accent/30'
}`}
>
<div className="flex-1 min-w-0">
<p className={`text-xs font-medium ${isEditing ? 'text-primary' : 'text-white'}`}>
Hole {index + 1} {isEditing && '(Editing)'}
</p>
<p className="text-[10px] text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} pts
</p>
</div>
<div className="flex items-center gap-1">
{isEditing ? (
<ActionButton
label="Done"
onClick={() => setEditingHole(null)}
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
/>
) : (
<>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground"
onClick={() => handleEditHole(index)}
>
<Edit className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300"
onClick={() => handleDeleteHole(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
)
})}
</div>
) : (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
No holes
</div>
)}
<div className="px-1 pt-1 pb-1">
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Hole"
onClick={handleAddHole}
className="w-full"
disabled={editingHole?.nodeId === selectedId}
/>
</div>
</PanelSection>
</PanelWrapper>
)
}
+38 -64
View File
@@ -2,10 +2,11 @@
import { type AnyNode, type AnyNodeId, type WallNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { X } from 'lucide-react'
import Image from 'next/image'
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() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -41,68 +42,41 @@ export function WallPanel() {
const thickness = node.thickness ?? 0.1
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">
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex items-center gap-2 min-w-0">
<Image src="/icons/wall.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
<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" />
</button>
</div>
<PanelWrapper
title={node.name || "Wall"}
icon="/icons/wall.png"
onClose={handleClose}
width={280}
>
<PanelSection title="Dimensions">
<SliderControl
label="Height"
value={Math.round(height * 100) / 100}
onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })}
min={0.1}
max={6}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label="Thickness"
value={Math.round(thickness * 1000) / 1000}
onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })}
min={0.05}
max={1}
precision={3}
step={0.01}
unit="m"
/>
</PanelSection>
{/* 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"
value={Math.round(height * 100) / 100}
onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })}
min={0.1}
precision={2}
step={0.1}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Thickness"
value={Math.round(thickness * 1000) / 1000}
onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })}
min={0.05}
precision={3}
step={0.01}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<PanelSection title="Info">
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
<span>Length</span>
<span className="font-mono text-white">{length.toFixed(2)} m</span>
</div>
{/* Info */}
<div className="space-y-2">
<label className="font-medium font-barlow text-muted-foreground text-xs uppercase tracking-wide">
Info
</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>
</PanelSection>
</PanelWrapper>
)
}
+207 -274
View File
@@ -2,13 +2,17 @@
import { type AnyNode, type AnyNodeId, WindowNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, FlipHorizontal2, Move, Trash2, X } from 'lucide-react'
import Image from 'next/image'
import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '@/lib/sfx-bus'
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() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -80,7 +84,7 @@ export function WindowPanel() {
sill: node.sill,
sillDepth: node.sillDepth,
sillThickness: node.sillThickness,
metadata: { isNew: true },
metadata: { isNew: true },
})
useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
setMovingNode(duplicate)
@@ -92,7 +96,6 @@ export function WindowPanel() {
const numCols = node.columnRatios.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 rowSum = node.rowRatios.reduce((a, b) => a + b, 0)
const normCols = node.columnRatios.map(r => r / colSum)
@@ -125,292 +128,222 @@ export function WindowPanel() {
}
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">
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex items-center gap-2 min-w-0">
<Image src="/icons/window.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
<h2 className="font-semibold font-barlow text-foreground text-sm truncate">
{node.name || "Window"}
</h2>
<PanelWrapper
title={node.name || "Window"}
icon="/icons/window.png"
onClose={handleClose}
width={320}
>
<PanelSection title="Position">
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[0] * 100) / 100}
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
min={-10}
max={10}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[1] * 100) / 100}
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
min={-10}
max={10}
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>
<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" />
</button>
</div>
</PanelSection>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<PanelSection title="Dimensions">
<SliderControl
label="Width"
value={Math.round(node.width * 100) / 100}
onChange={(v) => handleUpdate({ width: v })}
min={0.2}
max={5}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label="Height"
value={Math.round(node.height * 100) / 100}
onChange={(v) => handleUpdate({ height: v })}
min={0.2}
max={5}
precision={2}
step={0.1}
unit="m"
/>
</PanelSection>
{/* 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}
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
precision={2}
/>
<NumberInput
label="Y"
value={Math.round(node.position[1] * 100) / 100}
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
precision={2}
/>
</div>
<button
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>
<PanelSection title="Frame">
<SliderControl
label="Thickness"
value={Math.round(node.frameThickness * 1000) / 1000}
onChange={(v) => handleUpdate({ frameThickness: v })}
min={0.01}
max={0.2}
precision={3}
step={0.01}
unit="m"
/>
<SliderControl
label="Depth"
value={Math.round(node.frameDepth * 1000) / 1000}
onChange={(v) => handleUpdate({ frameDepth: v })}
min={0.01}
max={0.3}
precision={3}
step={0.01}
unit="m"
/>
</PanelSection>
{/* Dimensions */}
<div className="space-y-2">
<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"
value={Math.round(node.width * 100) / 100}
onChange={(v) => handleUpdate({ width: v })}
min={0.2}
precision={2}
className="flex-1"
<PanelSection title="Grid">
<SliderControl
label="Columns"
value={numCols}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
}}
min={1}
max={8}
precision={0}
step={1}
/>
<SliderControl
label="Rows"
value={numRows}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
}}
min={1}
max={8}
precision={0}
step={1}
/>
{numCols > 1 && (
<div className="mt-2 flex flex-col gap-1">
<div className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Col Widths</div>
{normCols.map((ratio, i) => (
<SliderControl
key={`c-${i}`}
label={`C${i + 1}`}
value={Math.round(ratio * 100 * 10) / 10}
onChange={(v) => setColumnRatio(i, v / 100)}
min={5}
max={95}
precision={1}
step={1}
unit="%"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Height"
value={Math.round(node.height * 100) / 100}
onChange={(v) => handleUpdate({ height: v })}
min={0.2}
precision={2}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
</div>
{/* Frame */}
<div className="space-y-2">
<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"
value={Math.round(node.frameThickness * 1000) / 1000}
onChange={(v) => handleUpdate({ frameThickness: v })}
min={0.01}
))}
<div className="mt-1 border-t border-border/50 pt-1">
<SliderControl
label="Divider"
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
min={0.005}
max={0.1}
precision={3}
step={0.01}
className="flex-1"
unit="m"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Depth"
value={Math.round(node.frameDepth * 1000) / 1000}
onChange={(v) => handleUpdate({ frameDepth: v })}
min={0.01}
</div>
)}
{numRows > 1 && (
<div className="mt-2 flex flex-col gap-1">
<div className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Row Heights</div>
{normRows.map((ratio, i) => (
<SliderControl
key={`r-${i}`}
label={`R${i + 1}`}
value={Math.round(ratio * 100 * 10) / 10}
onChange={(v) => setRowRatio(i, v / 100)}
min={5}
max={95}
precision={1}
step={1}
unit="%"
/>
))}
<div className="mt-1 border-t border-border/50 pt-1">
<SliderControl
label="Divider"
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
min={0.005}
max={0.1}
precision={3}
step={0.01}
className="flex-1"
unit="m"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
</div>
)}
</PanelSection>
{/* Grid */}
<div className="space-y-2">
<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"
value={numCols}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
}}
min={1}
max={8}
precision={0}
step={1}
<PanelSection title="Sill">
<ToggleControl
label="Enable Sill"
checked={node.sill}
onChange={(checked) => handleUpdate({ sill: checked })}
/>
{node.sill && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Depth"
value={Math.round(node.sillDepth * 1000) / 1000}
onChange={(v) => handleUpdate({ sillDepth: v })}
min={0.01}
max={0.5}
precision={3}
step={0.01}
unit="m"
/>
<NumberInput
label="Rows"
value={numRows}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
}}
min={1}
max={8}
precision={0}
step={1}
<SliderControl
label="Thickness"
value={Math.round(node.sillThickness * 1000) / 1000}
onChange={(v) => handleUpdate({ sillThickness: v })}
min={0.005}
max={0.2}
precision={3}
step={0.01}
unit="m"
/>
</div>
)}
</PanelSection>
{/* Column ratios */}
{numCols > 1 && (
<div className="space-y-1">
<span className="text-muted-foreground text-xs">Column widths</span>
{normCols.map((ratio, i) => (
<div key={i} className="flex items-center gap-1.5">
<NumberInput
label={`C${i + 1}`}
value={Math.round(ratio * 100 * 10) / 10}
onChange={(v) => setColumnRatio(i, v / 100)}
min={5}
max={95}
precision={1}
step={1}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">%</span>
</div>
))}
<div className="flex items-center gap-1.5">
<NumberInput
label="Col divider"
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
min={0.005}
precision={3}
step={0.01}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
)}
{/* Row ratios */}
{numRows > 1 && (
<div className="space-y-1">
<span className="text-muted-foreground text-xs">Row heights</span>
{normRows.map((ratio, i) => (
<div key={i} className="flex items-center gap-1.5">
<NumberInput
label={`R${i + 1}`}
value={Math.round(ratio * 100 * 10) / 10}
onChange={(v) => setRowRatio(i, v / 100)}
min={5}
max={95}
precision={1}
step={1}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">%</span>
</div>
))}
<div className="flex items-center gap-1.5">
<NumberInput
label="Row divider"
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
min={0.005}
precision={3}
step={0.01}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
)}
</div>
{/* Sill */}
<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">
Sill
</label>
<Switch
checked={node.sill}
onCheckedChange={(checked) => handleUpdate({ sill: checked })}
/>
</div>
{node.sill && (
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5">
<NumberInput
label="Depth"
value={Math.round(node.sillDepth * 1000) / 1000}
onChange={(v) => handleUpdate({ sillDepth: v })}
min={0.01}
precision={3}
step={0.01}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Thickness"
value={Math.round(node.sillThickness * 1000) / 1000}
onChange={(v) => handleUpdate({ sillThickness: v })}
min={0.005}
precision={3}
step={0.01}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
)}
</div>
</div>
{/* Action Buttons */}
<div className="border-t border-border/50 p-3 bg-white/50 dark:bg-transparent">
<div className="flex gap-2">
<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={handleMove}
>
<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}
>
<Trash2 className="h-3.5 w-3.5" />
<span>Delete</span>
</button>
</div>
</div>
</div>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
<ActionButton
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
label="Delete"
onClick={handleDelete}
className="hover:bg-red-500/20"
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
@@ -131,7 +131,7 @@ export function AppSidebar() {
{mounted && (
<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')}
type="button"
aria-label="Toggle theme"
@@ -139,7 +139,7 @@ export function AppSidebar() {
<div className="relative flex">
{/* Sliding Background */}
<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}
animate={{
x: theme === "light" ? "100%" : "0%",
@@ -779,13 +779,13 @@ function LayerToggle() {
phase === "structure" && structureLayer === "zones" ? "zones" : "none";
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
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",
activeTab === "structure"
? "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={() => {
setPhase("structure");
@@ -795,7 +795,7 @@ function LayerToggle() {
{activeTab === "structure" && (
<motion.div
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 }}
/>
)}
@@ -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",
activeTab === "furnish"
? "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={() => {
setPhase("furnish");
@@ -828,7 +828,7 @@ function LayerToggle() {
{activeTab === "furnish" && (
<motion.div
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 }}
/>
)}
@@ -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",
activeTab === "zones"
? "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={() => {
setPhase("structure");
@@ -862,7 +862,7 @@ function LayerToggle() {
{activeTab === "zones" && (
<motion.div
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 }}
/>
)}