From f1d0d3a78cf3c00e0fe1a54c95709e6763e30f37 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 1 Mar 2026 02:35:19 -0500 Subject: [PATCH] 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 --- .../components/ui/controls/action-button.tsx | 31 + .../components/ui/controls/metric-control.tsx | 246 ++++++ .../components/ui/controls/panel-section.tsx | 67 ++ .../ui/controls/segmented-control.tsx | 40 + .../components/ui/controls/slider-control.tsx | 305 +++++++ .../components/ui/controls/toggle-control.tsx | 40 + .../components/ui/panels/ceiling-panel.tsx | 278 +++--- .../components/ui/panels/door-panel.tsx | 834 ++++++++---------- .../components/ui/panels/item-panel.tsx | 378 ++++---- .../components/ui/panels/panel-wrapper.tsx | 79 ++ .../components/ui/panels/reference-panel.tsx | 238 +++-- .../components/ui/panels/roof-panel.tsx | 324 +++---- .../components/ui/panels/slab-panel.tsx | 286 +++--- .../components/ui/panels/wall-panel.tsx | 102 +-- .../components/ui/panels/window-panel.tsx | 481 +++++----- .../components/ui/sidebar/app-sidebar.tsx | 4 +- .../ui/sidebar/panels/site-panel/index.tsx | 14 +- 17 files changed, 2026 insertions(+), 1721 deletions(-) create mode 100644 apps/editor/components/ui/controls/action-button.tsx create mode 100644 apps/editor/components/ui/controls/metric-control.tsx create mode 100644 apps/editor/components/ui/controls/panel-section.tsx create mode 100644 apps/editor/components/ui/controls/segmented-control.tsx create mode 100644 apps/editor/components/ui/controls/slider-control.tsx create mode 100644 apps/editor/components/ui/controls/toggle-control.tsx create mode 100644 apps/editor/components/ui/panels/panel-wrapper.tsx diff --git a/apps/editor/components/ui/controls/action-button.tsx b/apps/editor/components/ui/controls/action-button.tsx new file mode 100644 index 00000000..e6ef6714 --- /dev/null +++ b/apps/editor/components/ui/controls/action-button.tsx @@ -0,0 +1,31 @@ +'use client' + +import { cn } from '@/lib/utils' + +interface ActionButtonProps extends React.ButtonHTMLAttributes { + icon?: React.ReactNode + label: string +} + +export function ActionButton({ icon, label, className, ...props }: ActionButtonProps) { + return ( + + ) +} + +export function ActionGroup({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ) +} diff --git a/apps/editor/components/ui/controls/metric-control.tsx b/apps/editor/components/ui/controls/metric-control.tsx new file mode 100644 index 00000000..d0374beb --- /dev/null +++ b/apps/editor/components/ui/controls/metric-control.tsx @@ -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(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) => { + 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) => { + 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 ( +
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)} + > +
+ {label} +
+ +
+ {isEditing ? ( +
+ + {unit && {unit}} +
+ ) : ( +
+ + {Number(value.toFixed(precision)).toFixed(precision)} + + {unit && {unit}} +
+ )} +
+
+ ) +} diff --git a/apps/editor/components/ui/controls/panel-section.tsx b/apps/editor/components/ui/controls/panel-section.tsx new file mode 100644 index 00000000..6e95a90d --- /dev/null +++ b/apps/editor/components/ui/controls/panel-section.tsx @@ -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 ( + + 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" + )} + > + {title} + + + + + {isExpanded && ( + +
+ {children} +
+
+ )} +
+
+ ) +} diff --git a/apps/editor/components/ui/controls/segmented-control.tsx b/apps/editor/components/ui/controls/segmented-control.tsx new file mode 100644 index 00000000..729b5380 --- /dev/null +++ b/apps/editor/components/ui/controls/segmented-control.tsx @@ -0,0 +1,40 @@ +'use client' + +import { cn } from '@/lib/utils' + +interface SegmentedControlProps { + value: T + onChange: (value: T) => void + options: { label: React.ReactNode; value: T }[] + className?: string +} + +export function SegmentedControl({ + value, + onChange, + options, + className, +}: SegmentedControlProps) { + return ( +
+ {options.map((option) => { + const isSelected = value === option.value + return ( + + ) + })} +
+ ) +} diff --git a/apps/editor/components/ui/controls/slider-control.tsx b/apps/editor/components/ui/controls/slider-control.tsx new file mode 100644 index 00000000..d308ff81 --- /dev/null +++ b/apps/editor/components/ui/controls/slider-control.tsx @@ -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(null) + const [dragMin, setDragMin] = useState(null) + const [dragMax, setDragMax] = useState(null) + + const trackRef = useRef(null) + const containerRef = useRef(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) => { + 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) => { + 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 ( +
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 && ( + + )} + +
+ {label} +
+ +
+ {/* Track dots background */} +
+ {[...Array(9)].map((_, i) => ( +
+ ))} +
+ + {/* Original Thumb Ghost */} + {isDragging && startPercent !== null && ( +
+ )} + + {/* Active Thumb */} +
+
+ +
+ {isEditing ? ( +
+ + {unit && {unit}} +
+ ) : ( +
+ + {Number(value.toFixed(precision)).toFixed(precision)} + + {unit && {unit}} +
+ )} +
+
+ ) +} diff --git a/apps/editor/components/ui/controls/toggle-control.tsx b/apps/editor/components/ui/controls/toggle-control.tsx new file mode 100644 index 00000000..707fec07 --- /dev/null +++ b/apps/editor/components/ui/controls/toggle-control.tsx @@ -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 ( +
onChange(!checked)} + > +
+ {label} +
+ +
+ +
+
+ ) +} diff --git a/apps/editor/components/ui/panels/ceiling-panel.tsx b/apps/editor/components/ui/panels/ceiling-panel.tsx index b85d324b..5a2d9a4c 100644 --- a/apps/editor/components/ui/panels/ceiling-panel.tsx +++ b/apps/editor/components/ui/panels/ceiling-panel.tsx @@ -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 ( -
- {/* Header */} -
-
- -

- {node.name || "Ceiling"} -

+ + + handleUpdate({ height: v })} + min={0} + max={6} + precision={3} + step={0.01} + unit="m" + /> + +
+ handleUpdate({ height: 2.4 })} /> + handleUpdate({ height: 2.5 })} /> + handleUpdate({ height: 3.0 })} />
- -
+ - {/* Content */} -
-
- {/* Height */} -
- -
- { - handleUpdate({ height: value }) - }} - precision={3} - className="flex-1" - /> - m -
-

- Height from the floor where the ceiling is positioned -

-
- - {/* Quick preset buttons */} -
- -
- - - -
-
- - {/* Area info */} -
- -
- {area.toFixed(2)} m² -
-
- - {/* Holes */} -
-
- - {editingHole?.nodeId === selectedId ? ( - - ) : ( - - )} -
- {node.holes && node.holes.length > 0 ? ( -
- {node.holes.map((hole, index) => { - const holeArea = calculateArea(hole) - const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index - return ( -
-
-

- Hole {index + 1} {isEditing && '(Editing)'} -

-

- {holeArea.toFixed(2)} m² · {hole.length} vertices -

-
-
- {!isEditing && ( - <> - - - - )} -
-
- ) - })} -
- ) : ( -

- No holes. Click "Add Hole" to create one. -

- )} -
+ +
+ Area + {area.toFixed(2)} m²
-
-
+ + + + {node.holes && node.holes.length > 0 ? ( +
+ {node.holes.map((hole, index) => { + const holeArea = calculateArea(hole) + const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index + return ( +
+
+

+ Hole {index + 1} {isEditing && '(Editing)'} +

+

+ {holeArea.toFixed(2)} m² · {hole.length} pts +

+
+
+ {isEditing ? ( + setEditingHole(null)} + className="h-7 bg-primary text-primary-foreground hover:bg-primary/90" + /> + ) : ( + <> + + + + )} +
+
+ ) + })} +
+ ) : ( +
+ No holes +
+ )} + +
+ } + label="Add Hole" + onClick={handleAddHole} + className="w-full" + disabled={editingHole?.nodeId === selectedId} + /> +
+
+ ) } diff --git a/apps/editor/components/ui/panels/door-panel.tsx b/apps/editor/components/ui/panels/door-panel.tsx index 2ef0de4d..a050f83b 100644 --- a/apps/editor/components/ui/panels/door-panel.tsx +++ b/apps/editor/components/ui/panels/door-panel.tsx @@ -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 ( -
- {/* Header */} -
-
- -

- {node.name || "Door"} -

+ + + Xwall} + 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" + /> +
+ } + label="Flip Side" + onClick={handleFlip} + className="w-full" + />
- -
+ - {/* Content */} -
+ + handleUpdate({ width: v })} + min={0.5} + max={3} + precision={2} + step={0.05} + unit="m" + /> + handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })} + min={1.0} + max={4} + precision={2} + step={0.05} + unit="m" + /> + - {/* Position */} -
- -
- handleUpdate({ position: [v, node.position[1], node.position[2]] })} + + handleUpdate({ frameThickness: v })} + min={0.01} + max={0.2} + precision={3} + step={0.01} + unit="m" + /> + handleUpdate({ frameDepth: v })} + min={0.01} + max={0.3} + precision={3} + step={0.01} + unit="m" + /> + + + + handleUpdate({ contentPadding: [v, node.contentPadding[1]] })} + min={0} + max={0.2} + precision={3} + step={0.005} + unit="m" + /> + handleUpdate({ contentPadding: [node.contentPadding[0], v] })} + min={0} + max={0.2} + precision={3} + step={0.005} + unit="m" + /> + + + +
+
+ Hinges Side + handleUpdate({ hingesSide: v })} + options={[ + { label: 'Left', value: 'left' }, + { label: 'Right', value: 'right' }, + ]} + /> +
+
+ Direction + handleUpdate({ swingDirection: v })} + options={[ + { label: 'Inward', value: 'inward' }, + { label: 'Outward', value: 'outward' }, + ]} + /> +
+
+
+ + + handleUpdate({ threshold: checked })} + /> + {node.threshold && ( +
+ handleUpdate({ thresholdHeight: v })} + min={0.005} + max={0.1} + precision={3} + step={0.005} + unit="m" + /> +
+ )} +
+ + + handleUpdate({ handle: checked })} + /> + {node.handle && ( +
+ handleUpdate({ handleHeight: v })} + min={0.5} + max={node.height - 0.1} precision={2} + step={0.05} + unit="m" /> -
- -
- - {/* Dimensions */} -
- -
-
- handleUpdate({ width: v })} - min={0.5} - precision={2} - className="flex-1" - /> - m -
-
- handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })} - min={1.0} - precision={2} - className="flex-1" - /> - m -
-
-
- - {/* Frame */} -
- -
-
- handleUpdate({ frameThickness: v })} - min={0.01} - precision={3} - step={0.01} - className="flex-1" - /> - m -
-
- handleUpdate({ frameDepth: v })} - min={0.01} - precision={3} - step={0.01} - className="flex-1" - /> - m -
-
-
- - {/* Content Padding */} -
- -
-
- handleUpdate({ contentPadding: [v, node.contentPadding[1]] })} - min={0} - precision={3} - step={0.005} - className="flex-1" - /> - m -
-
- handleUpdate({ contentPadding: [node.contentPadding[0], v] })} - min={0} - precision={3} - step={0.005} - className="flex-1" - /> - m -
-
-
- - {/* Swing */} -
- -
- Hinges -
- {(['left', 'right'] as const).map((side) => ( - - ))} -
-
-
- Direction -
- {(['inward', 'outward'] as const).map((dir) => ( - - ))} -
-
-
-
- - {/* Threshold */} -
-
- - handleUpdate({ threshold: checked })} - /> -
- {node.threshold && ( -
- handleUpdate({ thresholdHeight: v })} - min={0.005} - precision={3} - step={0.005} - className="flex-1" + Handle Side + handleUpdate({ handleSide: v })} + options={[ + { label: 'Left', value: 'left' }, + { label: 'Right', value: 'right' }, + ]} /> - m
- )} -
+
+ )} + - {/* Handle */} -
-
- - handleUpdate({ handle: checked })} + + handleUpdate({ doorCloser: checked })} + /> + handleUpdate({ panicBar: checked })} + /> + {node.panicBar && ( +
+ handleUpdate({ panicBarHeight: v })} + min={0.5} + max={node.height - 0.1} + precision={2} + step={0.05} + unit="m" />
- {node.handle && ( -
-
- handleUpdate({ handleHeight: v })} - min={0.5} - max={node.height - 0.1} - precision={2} - step={0.05} - className="flex-1" - /> - m + )} + + + + {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 ( +
+
+ Segment {i + 1}
-
- Side -
- {(['left', 'right'] as const).map((side) => ( - + + { + 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' }, + ]} + /> + + setSegmentHeightRatio(i, v / 100)} + min={5} + max={95} + precision={1} + step={1} + unit="%" + /> + + { + 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 && ( +
+ {normCols.map((ratio, ci) => ( + setSegmentColumnRatio(i, ci, v / 100)} + min={5} + max={95} + precision={1} + step={1} + unit="%" + /> ))} -
-
-
- )} -
- - {/* Hardware */} -
- -
-
- Door Closer - handleUpdate({ doorCloser: checked })} - /> -
-
- Panic Bar - handleUpdate({ panicBar: checked })} - /> -
- {node.panicBar && ( -
- handleUpdate({ panicBarHeight: v })} - min={0.5} - max={node.height - 0.1} - precision={2} - step={0.05} - className="flex-1" - /> - m -
- )} -
-
- - {/* 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 ( -
-
- Segment {i + 1} -
- {(['panel', 'glass', 'empty'] as const).map((t) => ( - - ))} -
-
-
- setSegmentHeightRatio(i, v / 100)} - min={5} - max={95} - precision={1} - step={1} - className="flex-1" - /> - % -
- {/* Columns */} -
- { - 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 && ( -
- {normCols.map((ratio, ci) => ( -
- setSegmentColumnRatio(i, ci, v / 100)} - min={5} - max={95} - precision={1} - step={1} - className="flex-1" - /> - % -
- ))} -
- { - 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" - /> - m -
-
- )}
- {seg.type === 'panel' && ( -
-
- { - 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" - /> - m -
-
- { - 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" - /> - m -
-
- )} -
- ) - })} -
- - {node.segments.length > 1 && ( - - )} -
-
-
+ )} - {/* Action Buttons */} -
-
- - - + {seg.type === 'panel' && ( +
+ { + 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" + /> + { + 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" + /> +
+ )} +
+ ) + })} + +
+ { + 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 && ( + handleUpdate({ segments: node.segments.slice(0, -1) })} + className="text-white/60 hover:text-white" + /> + )}
-
-
+
+ + + + } label="Move" onClick={handleMove} /> + } label="Duplicate" onClick={handleDuplicate} /> + } + label="Delete" + onClick={handleDelete} + className="hover:bg-red-500/20" + /> + + + ) } diff --git a/apps/editor/components/ui/panels/item-panel.tsx b/apps/editor/components/ui/panels/item-panel.tsx index b9173929..2bf67a76 100644 --- a/apps/editor/components/ui/panels/item-panel.tsx +++ b/apps/editor/components/ui/panels/item-panel.tsx @@ -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 ( -
- {/* Header */} -
-
- + + Xpos} + 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" + /> + Ypos} + 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" + /> + Zpos} + 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" + /> + + + + Yrot} + 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="°" + /> +
+ { + 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]] }) + }} + /> + { + 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]] }) + }} /> -

- {node.name || node.asset.name} -

- -
+ - {/* Content */} -
-
- {/* Position */} -
- -
- { - handleUpdate({ position: [value, node.position[1], node.position[2]] }) - }} - precision={2} - /> - { - handleUpdate({ position: [node.position[0], value, node.position[2]] }) - }} - precision={2} - /> - { - handleUpdate({ position: [node.position[0], node.position[1], value] }) - }} - precision={2} - /> -
-
- - {/* Rotation */} -
- -
- { - const radians = (degrees * Math.PI) / 180 - handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] }) - }} - precision={0} - className="flex-1" - /> - ° -
-
- - -
-
- - {/* Scale */} -
-
- - -
- {uniformScale ? ( - { - const v = Math.max(0.01, value) - handleUpdate({ scale: [v, v, v] }) - }} - precision={2} - step={0.1} - /> - ) : ( -
- { - handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] }) - }} - precision={2} - step={0.1} - /> - { - handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] }) - }} - precision={2} - step={0.1} - /> - { - handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] }) - }} - precision={2} - step={0.1} - /> -
+ +
+ Uniform Scale +
-
- - {/* Action Buttons */} -
-
- - -
-
-
+ + {uniformScale ? ( + XYZscale} + 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} + /> + ) : ( + <> + Xscale} + 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} + /> + Yscale} + 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} + /> + Zscale} + 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} + /> + + )} + + + +
+ Dimensions + {(() => { + const [w, h, d] = getScaledDimensions(node) + return ( + + {Math.round(w * 100) / 100}×{Math.round(h * 100) / 100}×{Math.round(d * 100) / 100} + + ) + })()} +
+
+ + + + } label="Move" onClick={handleMove} /> + } label="Duplicate" onClick={handleDuplicate} /> + } + label="Delete" + onClick={handleDelete} + className="hover:bg-red-500/20" + /> + + + ) } diff --git a/apps/editor/components/ui/panels/panel-wrapper.tsx b/apps/editor/components/ui/panels/panel-wrapper.tsx new file mode 100644 index 00000000..cee35cee --- /dev/null +++ b/apps/editor/components/ui/panels/panel-wrapper.tsx @@ -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 ( +
+ {/* Header */} +
+
+ {icon && ( + + )} +

+ {title} +

+
+ +
+ {onReset && ( + + )} + {onClose && ( + + )} +
+
+ + {/* Content */} +
+ {children} +
+
+ ) +} diff --git a/apps/editor/components/ui/panels/reference-panel.tsx b/apps/editor/components/ui/panels/reference-panel.tsx index 2ba02455..0ba76d12 100644 --- a/apps/editor/components/ui/panels/reference-panel.tsx +++ b/apps/editor/components/ui/panels/reference-panel.tsx @@ -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 ( -
- {/* Header */} -
-
- {isScan ? ( - - ) : ( - - )} -

- {node.name || (isScan ? '3D Scan' : 'Guide Image')} -

+ + + Xpos} + 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" + /> + Ypos} + 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" + /> + Zpos} + 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" + /> + + + + Yrot} + 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="°" + /> +
+ handleUpdate({ rotation: [node.rotation[0], node.rotation[1] - Math.PI / 4, node.rotation[2]] })} + /> + handleUpdate({ rotation: [node.rotation[0], node.rotation[1] + Math.PI / 4, node.rotation[2]] })} + />
- -
+ - {/* Content */} -
-
- {/* Position */} -
- -
- {([0, 1, 2] as const).map((i) => ( - { - const pos = [...node.position] as [number, number, number] - pos[i] = value - handleUpdate({ position: pos }) - }} - precision={2} - /> - ))} -
-
- - {/* Rotation Y */} -
- -
- { - const radians = (degrees * Math.PI) / 180 - handleUpdate({ - rotation: [node.rotation[0], radians, node.rotation[2]], - }) - }} - precision={0} - className="min-w-0 flex-1" - /> - ° - - -
-
- - {/* Scale */} -
- - { - if (value > 0) { - handleUpdate({ scale: value }) - } - }} - min={0.01} - precision={2} - /> -
- - {/* Opacity */} -
-
- - {node.opacity}% -
- handleUpdate({ opacity: Number.parseInt(e.target.value, 10) })} - step="1" - type="range" - value={node.opacity} - /> -
-
-
-
+ + XYZscale} + 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} + /> + + handleUpdate({ opacity: v })} + min={0} + max={100} + precision={0} + step={1} + unit="%" + /> + + ) } diff --git a/apps/editor/components/ui/panels/roof-panel.tsx b/apps/editor/components/ui/panels/roof-panel.tsx index 84bd5d39..d48204e6 100644 --- a/apps/editor/components/ui/panels/roof-panel.tsx +++ b/apps/editor/components/ui/panels/roof-panel.tsx @@ -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 ( -
- {/* Header */} -
-
- -

- {node.name || "Roof"} -

+ + + handleUpdate({ length: v })} + min={0.5} + max={20} + precision={2} + step={0.5} + unit="m" + /> + handleUpdate({ height: v })} + min={0.1} + max={10} + precision={2} + step={0.1} + unit="m" + /> + + + +
+ Widths + Total: {totalWidth.toFixed(1)}m
- -
+ handleUpdate({ leftWidth: v })} + min={0.1} + max={10} + precision={2} + step={0.1} + unit="m" + /> + handleUpdate({ rightWidth: v })} + min={0.1} + max={10} + precision={2} + step={0.1} + unit="m" + /> + - {/* Content */} -
-
- {/* Length */} -
- -
- { - 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} - /> - m -
-
- - {/* Height */} -
- -
- { - 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} - /> - m -
-
- - {/* Slope Widths */} -
-
- - - Total: {totalWidth.toFixed(1)}m - -
-
-
- -
- { - 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} - /> - m -
-
-
- -
- { - 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} - /> - m -
-
-
-
- - {/* Rotation */} -
- -
- { - 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)} - /> - ° - - -
-
- - {/* Position */} -
- -
- {([0, 1, 2] as const).map((i) => ( -
- - { - 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} - /> -
- ))} -
-
+ + Rrot} + 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="°" + /> +
+ handleUpdate({ rotation: node.rotation - Math.PI / 2 })} + /> + handleUpdate({ rotation: node.rotation + Math.PI / 2 })} + />
-
-
+ + + + Xpos} + 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" + /> + Ypos} + 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" + /> + Zpos} + 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" + /> + + ) } diff --git a/apps/editor/components/ui/panels/slab-panel.tsx b/apps/editor/components/ui/panels/slab-panel.tsx index 3493d4d3..7845acb1 100644 --- a/apps/editor/components/ui/panels/slab-panel.tsx +++ b/apps/editor/components/ui/panels/slab-panel.tsx @@ -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 ( -
- {/* Header */} -
-
- -

- {node.name || "Slab"} -

+ + + handleUpdate({ elevation: v })} + min={-1} + max={1} + precision={3} + step={0.01} + unit="m" + /> + +
+ handleUpdate({ elevation: -0.15 })} /> + handleUpdate({ elevation: 0 })} /> + handleUpdate({ elevation: 0.05 })} /> + handleUpdate({ elevation: 0.15 })} />
- -
+ - {/* Content */} -
-
- {/* Elevation */} -
- -
- { - handleUpdate({ elevation: value }) - }} - precision={3} - className="flex-1" - /> - m -
-

- Height offset from the level base (positive = raised, negative = sunken) -

-
- - {/* Quick preset buttons */} -
- -
- - - - -
-
- - {/* Area info */} -
- -
- {area.toFixed(2)} m² -
-
- - {/* Holes */} -
-
- - {editingHole?.nodeId === selectedId ? ( - - ) : ( - - )} -
- {node.holes && node.holes.length > 0 ? ( -
- {node.holes.map((hole, index) => { - const holeArea = calculateArea(hole) - const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index - return ( -
-
-

- Hole {index + 1} {isEditing && '(Editing)'} -

-

- {holeArea.toFixed(2)} m² · {hole.length} vertices -

-
-
- {!isEditing && ( - <> - - - - )} -
-
- ) - })} -
- ) : ( -

- No holes. Click "Add Hole" to create one. -

- )} -
+ +
+ Area + {area.toFixed(2)} m²
-
-
+ + + + {node.holes && node.holes.length > 0 ? ( +
+ {node.holes.map((hole, index) => { + const holeArea = calculateArea(hole) + const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index + return ( +
+
+

+ Hole {index + 1} {isEditing && '(Editing)'} +

+

+ {holeArea.toFixed(2)} m² · {hole.length} pts +

+
+
+ {isEditing ? ( + setEditingHole(null)} + className="h-7 bg-primary text-primary-foreground hover:bg-primary/90" + /> + ) : ( + <> + + + + )} +
+
+ ) + })} +
+ ) : ( +
+ No holes +
+ )} + +
+ } + label="Add Hole" + onClick={handleAddHole} + className="w-full" + disabled={editingHole?.nodeId === selectedId} + /> +
+
+ ) } diff --git a/apps/editor/components/ui/panels/wall-panel.tsx b/apps/editor/components/ui/panels/wall-panel.tsx index 208df83e..64e7beaa 100644 --- a/apps/editor/components/ui/panels/wall-panel.tsx +++ b/apps/editor/components/ui/panels/wall-panel.tsx @@ -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 ( -
- {/* Header */} -
-
- -

- {node.name || "Wall"} -

-
- -
+ + + handleUpdate({ height: Math.max(0.1, v) })} + min={0.1} + max={6} + precision={2} + step={0.1} + unit="m" + /> + handleUpdate({ thickness: Math.max(0.05, v) })} + min={0.05} + max={1} + precision={3} + step={0.01} + unit="m" + /> + - {/* Content */} -
- - {/* Dimensions */} -
- -
- handleUpdate({ height: Math.max(0.1, v) })} - min={0.1} - precision={2} - step={0.1} - className="flex-1" - /> - m -
-
- handleUpdate({ thickness: Math.max(0.05, v) })} - min={0.05} - precision={3} - step={0.01} - className="flex-1" - /> - m -
+ +
+ Length + {length.toFixed(2)} m
- - {/* Info */} -
- -
- Length: {length.toFixed(2)} m -
-
-
-
+ +
) } diff --git a/apps/editor/components/ui/panels/window-panel.tsx b/apps/editor/components/ui/panels/window-panel.tsx index d2d37376..a721b8a4 100644 --- a/apps/editor/components/ui/panels/window-panel.tsx +++ b/apps/editor/components/ui/panels/window-panel.tsx @@ -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 ( -
- {/* Header */} -
-
- -

- {node.name || "Window"} -

+ + + Xpos} + 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" + /> + Ypos} + 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" + /> +
+ } + label="Flip Side" + onClick={handleFlip} + className="w-full" + />
- -
+ - {/* Content */} -
+ + handleUpdate({ width: v })} + min={0.2} + max={5} + precision={2} + step={0.1} + unit="m" + /> + handleUpdate({ height: v })} + min={0.2} + max={5} + precision={2} + step={0.1} + unit="m" + /> + - {/* Position */} -
- -
- handleUpdate({ position: [v, node.position[1], node.position[2]] })} - precision={2} - /> - handleUpdate({ position: [node.position[0], v, node.position[2]] })} - precision={2} - /> -
- -
+ + handleUpdate({ frameThickness: v })} + min={0.01} + max={0.2} + precision={3} + step={0.01} + unit="m" + /> + handleUpdate({ frameDepth: v })} + min={0.01} + max={0.3} + precision={3} + step={0.01} + unit="m" + /> + - {/* Dimensions */} -
- -
-
- handleUpdate({ width: v })} - min={0.2} - precision={2} - className="flex-1" + + { + 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} + /> + { + 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 && ( +
+
Col Widths
+ {normCols.map((ratio, i) => ( + setColumnRatio(i, v / 100)} + min={5} + max={95} + precision={1} + step={1} + unit="%" /> - m -
-
- handleUpdate({ height: v })} - min={0.2} - precision={2} - className="flex-1" - /> - m -
-
-
- - {/* Frame */} -
- -
-
- handleUpdate({ frameThickness: v })} - min={0.01} + ))} +
+ handleUpdate({ columnDividerThickness: v })} + min={0.005} + max={0.1} precision={3} step={0.01} - className="flex-1" + unit="m" /> - m
-
- handleUpdate({ frameDepth: v })} - min={0.01} +
+ )} + + {numRows > 1 && ( +
+
Row Heights
+ {normRows.map((ratio, i) => ( + setRowRatio(i, v / 100)} + min={5} + max={95} + precision={1} + step={1} + unit="%" + /> + ))} +
+ handleUpdate({ rowDividerThickness: v })} + min={0.005} + max={0.1} precision={3} step={0.01} - className="flex-1" + unit="m" /> - m
-
+ )} + - {/* Grid */} -
- -
- { - 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} + + handleUpdate({ sill: checked })} + /> + {node.sill && ( +
+ handleUpdate({ sillDepth: v })} + min={0.01} + max={0.5} + precision={3} + step={0.01} + unit="m" /> - { - 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} + handleUpdate({ sillThickness: v })} + min={0.005} + max={0.2} + precision={3} + step={0.01} + unit="m" />
+ )} +
- {/* Column ratios */} - {numCols > 1 && ( -
- Column widths - {normCols.map((ratio, i) => ( -
- setColumnRatio(i, v / 100)} - min={5} - max={95} - precision={1} - step={1} - className="flex-1" - /> - % -
- ))} -
- handleUpdate({ columnDividerThickness: v })} - min={0.005} - precision={3} - step={0.01} - className="flex-1" - /> - m -
-
- )} - - {/* Row ratios */} - {numRows > 1 && ( -
- Row heights - {normRows.map((ratio, i) => ( -
- setRowRatio(i, v / 100)} - min={5} - max={95} - precision={1} - step={1} - className="flex-1" - /> - % -
- ))} -
- handleUpdate({ rowDividerThickness: v })} - min={0.005} - precision={3} - step={0.01} - className="flex-1" - /> - m -
-
- )} -
- - {/* Sill */} -
-
- - handleUpdate({ sill: checked })} - /> -
- {node.sill && ( -
-
- handleUpdate({ sillDepth: v })} - min={0.01} - precision={3} - step={0.01} - className="flex-1" - /> - m -
-
- handleUpdate({ sillThickness: v })} - min={0.005} - precision={3} - step={0.01} - className="flex-1" - /> - m -
-
- )} -
-
- - {/* Action Buttons */} -
-
- - - -
-
-
+ + + } label="Move" onClick={handleMove} /> + } label="Duplicate" onClick={handleDuplicate} /> + } + label="Delete" + onClick={handleDelete} + className="hover:bg-red-500/20" + /> + + + ) } diff --git a/apps/editor/components/ui/sidebar/app-sidebar.tsx b/apps/editor/components/ui/sidebar/app-sidebar.tsx index 43163799..bde83b38 100644 --- a/apps/editor/components/ui/sidebar/app-sidebar.tsx +++ b/apps/editor/components/ui/sidebar/app-sidebar.tsx @@ -131,7 +131,7 @@ export function AppSidebar() { {mounted && (