1014 lines
35 KiB
TypeScript
1014 lines
35 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
emitter,
|
|
useInteractive,
|
|
useScene,
|
|
WindowNode,
|
|
} from '@pascal-app/core'
|
|
import {
|
|
ActionButton,
|
|
ActionGroup,
|
|
cn,
|
|
PanelSection,
|
|
PanelWrapper,
|
|
PresetsPopover,
|
|
SegmentedControl,
|
|
SliderControl,
|
|
ToggleControl,
|
|
triggerSFX,
|
|
useEditor,
|
|
usePresetsAdapter,
|
|
} from '@pascal-app/editor'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
|
import { useCallback, useRef } from 'react'
|
|
|
|
function isSameWindowValue(current: unknown, next: unknown): boolean {
|
|
if (typeof current === 'number' && typeof next === 'number') {
|
|
return Math.abs(current - next) < 1e-6
|
|
}
|
|
|
|
if (Array.isArray(current) && Array.isArray(next)) {
|
|
return (
|
|
current.length === next.length &&
|
|
current.every((value, index) => isSameWindowValue(value, next[index]))
|
|
)
|
|
}
|
|
|
|
return Object.is(current, next)
|
|
}
|
|
|
|
function getMaxSharedWindowRadius(width: number, height: number) {
|
|
return Math.max(0, Math.min(width / 2, height / 2))
|
|
}
|
|
|
|
function normalizeWindowCornerRadii(
|
|
radii: [number, number, number, number],
|
|
width: number,
|
|
height: number,
|
|
): [number, number, number, number] {
|
|
const next = radii.map((radius) => Math.max(radius, 0)) as [number, number, number, number]
|
|
const scale = Math.min(
|
|
1,
|
|
Math.max(width, 0) / Math.max(next[0] + next[1], 1e-6),
|
|
Math.max(width, 0) / Math.max(next[3] + next[2], 1e-6),
|
|
Math.max(height, 0) / Math.max(next[0] + next[3], 1e-6),
|
|
Math.max(height, 0) / Math.max(next[1] + next[2], 1e-6),
|
|
)
|
|
|
|
if (scale >= 1) return next
|
|
|
|
return next.map((radius) => radius * scale) as [number, number, number, number]
|
|
}
|
|
|
|
function isSameRadiusTuple(
|
|
current: [number, number, number, number],
|
|
next: [number, number, number, number],
|
|
) {
|
|
return current.every((value, index) => Math.abs(value - (next[index] ?? 0)) < 1e-6)
|
|
}
|
|
|
|
const windowTypeOptions: Array<{ label: string; value: WindowNode['windowType'] }> = [
|
|
{ label: 'Fixed', value: 'fixed' },
|
|
{ label: 'Sliding', value: 'sliding' },
|
|
{ label: 'Casement', value: 'casement' },
|
|
{ label: 'Awning', value: 'awning' },
|
|
{ label: 'Single Hung', value: 'single-hung' },
|
|
{ label: 'Double Hung', value: 'double-hung' },
|
|
{ label: 'Bay', value: 'bay' },
|
|
{ label: 'Bow', value: 'bow' },
|
|
{ label: 'Louvered', value: 'louvered' },
|
|
]
|
|
|
|
const shapedWindowTypes = new Set<WindowNode['windowType']>([
|
|
'fixed',
|
|
'casement',
|
|
'awning',
|
|
'hopper',
|
|
'louvered',
|
|
])
|
|
|
|
const silllessWindowTypes = new Set<WindowNode['windowType']>(['bay', 'bow'])
|
|
|
|
export default function WindowPanel() {
|
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
|
const setSelection = useViewer((s) => s.setSelection)
|
|
const deleteNode = useScene((s) => s.deleteNode)
|
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
const previewRef = useRef<{
|
|
id: AnyNodeId
|
|
key: keyof WindowNode
|
|
value: unknown
|
|
} | null>(null)
|
|
|
|
const adapter = usePresetsAdapter()
|
|
|
|
const node = useScene((s) =>
|
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WindowNode | undefined) : undefined,
|
|
)
|
|
|
|
// Panel slider-drag fix recipe (plans/editor-node-registry.md). Without
|
|
// it, the 15+ SliderControls in this panel would loop on drag.
|
|
const handleUpdate = useCallback(
|
|
(updates: Partial<WindowNode>) => {
|
|
if (!selectedId) return
|
|
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
|
|
if (liveNode?.type !== 'window') return
|
|
|
|
const hasChange = Object.entries(updates).some(([key, value]) => {
|
|
const currentValue = liveNode[key as keyof WindowNode]
|
|
return !isSameWindowValue(currentValue, value)
|
|
})
|
|
if (!hasChange) return
|
|
|
|
useScene.getState().updateNode(selectedId as AnyNode['id'], updates)
|
|
const scene = useScene.getState()
|
|
scene.dirtyNodes.add(selectedId as AnyNodeId)
|
|
if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId)
|
|
},
|
|
[selectedId],
|
|
)
|
|
|
|
const previewWindowUpdate = useCallback(
|
|
<K extends keyof WindowNode>(key: K, value: WindowNode[K]) => {
|
|
if (!selectedId) return
|
|
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
|
|
if (liveNode?.type !== 'window') return
|
|
|
|
if (
|
|
!(
|
|
previewRef.current &&
|
|
previewRef.current.id === selectedId &&
|
|
previewRef.current.key === key
|
|
)
|
|
) {
|
|
previewRef.current = {
|
|
id: selectedId as AnyNodeId,
|
|
key,
|
|
value: liveNode[key],
|
|
}
|
|
}
|
|
|
|
if (isSameWindowValue(liveNode[key], value)) return
|
|
|
|
;(liveNode as WindowNode)[key] = value
|
|
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
|
|
},
|
|
[selectedId],
|
|
)
|
|
|
|
const commitWindowPreview = useCallback(
|
|
<K extends keyof WindowNode>(key: K, value: WindowNode[K]) => {
|
|
if (!selectedId) return
|
|
|
|
const scene = useScene.getState()
|
|
const liveNode = scene.nodes[selectedId as AnyNodeId]
|
|
const preview = previewRef.current
|
|
if (liveNode?.type === 'window' && preview?.id === selectedId && preview.key === key) {
|
|
;(liveNode as WindowNode)[key] = preview.value as WindowNode[K]
|
|
scene.dirtyNodes.add(selectedId as AnyNodeId)
|
|
}
|
|
previewRef.current = null
|
|
|
|
useScene.getState().updateNode(
|
|
selectedId as AnyNode['id'],
|
|
{
|
|
[key]: value,
|
|
} as Partial<WindowNode>,
|
|
)
|
|
scene.dirtyNodes.add(selectedId as AnyNodeId)
|
|
},
|
|
[selectedId],
|
|
)
|
|
|
|
const handleClose = useCallback(() => {
|
|
setSelection({ selectedIds: [] })
|
|
}, [setSelection])
|
|
|
|
const handleFlip = useCallback(() => {
|
|
if (!node) return
|
|
handleUpdate({
|
|
side: node.side === 'front' ? 'back' : 'front',
|
|
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
|
|
})
|
|
}, [node, handleUpdate])
|
|
|
|
const handleMove = useCallback(() => {
|
|
if (!node) return
|
|
triggerSFX('sfx:item-pick')
|
|
setMovingNode(node)
|
|
setSelection({ selectedIds: [] })
|
|
}, [node, setMovingNode, setSelection])
|
|
|
|
const handleDelete = useCallback(() => {
|
|
if (!(selectedId && node)) return
|
|
triggerSFX('sfx:item-delete')
|
|
deleteNode(selectedId as AnyNode['id'])
|
|
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
|
setSelection({ selectedIds: [] })
|
|
}, [selectedId, node, deleteNode, setSelection])
|
|
|
|
const handleDuplicate = useCallback(() => {
|
|
if (!node?.parentId) return
|
|
triggerSFX('sfx:item-pick')
|
|
useScene.temporal.getState().pause()
|
|
const duplicate = WindowNode.parse({
|
|
position: [...node.position] as [number, number, number],
|
|
rotation: [...node.rotation] as [number, number, number],
|
|
side: node.side,
|
|
wallId: node.wallId,
|
|
parentId: node.parentId,
|
|
width: node.width,
|
|
height: node.height,
|
|
windowType: node.windowType,
|
|
operationState: node.operationState,
|
|
awningDirection: node.awningDirection,
|
|
casementStyle: node.casementStyle,
|
|
hingesSide: node.hingesSide,
|
|
frameThickness: node.frameThickness,
|
|
frameDepth: node.frameDepth,
|
|
openingKind: node.openingKind,
|
|
openingShape: node.openingShape,
|
|
openingRadiusMode: node.openingRadiusMode ?? 'all',
|
|
openingCornerRadii: [...(node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15])],
|
|
cornerRadius: node.cornerRadius,
|
|
archHeight: node.archHeight,
|
|
openingRevealRadius: node.openingRevealRadius,
|
|
columnRatios: [...node.columnRatios],
|
|
rowRatios: [...node.rowRatios],
|
|
columnDividerThickness: node.columnDividerThickness,
|
|
rowDividerThickness: node.rowDividerThickness,
|
|
sill: node.sill,
|
|
sillDepth: node.sillDepth,
|
|
sillThickness: node.sillThickness,
|
|
metadata: { isNew: true },
|
|
})
|
|
useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
|
|
setMovingNode(duplicate)
|
|
setSelection({ selectedIds: [] })
|
|
}, [node, setMovingNode, setSelection])
|
|
|
|
const getWindowPresetData = useCallback(() => {
|
|
if (!node) return null
|
|
return {
|
|
width: node.width,
|
|
height: node.height,
|
|
windowType: node.windowType,
|
|
operationState: node.operationState,
|
|
awningDirection: node.awningDirection,
|
|
casementStyle: node.casementStyle,
|
|
hingesSide: node.hingesSide,
|
|
frameThickness: node.frameThickness,
|
|
frameDepth: node.frameDepth,
|
|
openingKind: node.openingKind,
|
|
openingShape: node.openingShape,
|
|
openingRadiusMode: node.openingRadiusMode ?? 'all',
|
|
openingCornerRadii: node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15],
|
|
cornerRadius: node.cornerRadius,
|
|
archHeight: node.archHeight,
|
|
openingRevealRadius: node.openingRevealRadius,
|
|
columnRatios: node.columnRatios,
|
|
rowRatios: node.rowRatios,
|
|
columnDividerThickness: node.columnDividerThickness,
|
|
rowDividerThickness: node.rowDividerThickness,
|
|
sill: node.sill,
|
|
sillDepth: node.sillDepth,
|
|
sillThickness: node.sillThickness,
|
|
}
|
|
}, [node])
|
|
|
|
const handleSavePreset = useCallback(
|
|
async (name: string) => {
|
|
const data = getWindowPresetData()
|
|
if (!(data && selectedId)) return
|
|
const presetId = await adapter.savePreset('window', name, data)
|
|
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
|
|
},
|
|
[getWindowPresetData, selectedId, adapter],
|
|
)
|
|
|
|
const handleOverwritePreset = useCallback(
|
|
async (id: string) => {
|
|
const data = getWindowPresetData()
|
|
if (!(data && selectedId)) return
|
|
await adapter.overwritePreset('window', id, data)
|
|
emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
|
|
},
|
|
[getWindowPresetData, selectedId, adapter],
|
|
)
|
|
|
|
const handleApplyPreset = useCallback(
|
|
(data: Record<string, unknown>) => {
|
|
handleUpdate(data as Partial<WindowNode>)
|
|
},
|
|
[handleUpdate],
|
|
)
|
|
|
|
if (!(node && node.type === 'window' && selectedId)) return null
|
|
|
|
const numCols = node.columnRatios.length
|
|
const numRows = node.rowRatios.length
|
|
|
|
const colSum = node.columnRatios.reduce((a, b) => a + b, 0)
|
|
const rowSum = node.rowRatios.reduce((a, b) => a + b, 0)
|
|
const normCols = node.columnRatios.map((r) => r / colSum)
|
|
const normRows = node.rowRatios.map((r) => r / rowSum)
|
|
const isOpening = node.openingKind === 'opening'
|
|
const openingShape = node.openingShape ?? 'rectangle'
|
|
const windowShape =
|
|
openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
|
|
const openingRadiusMode = node.openingRadiusMode ?? 'all'
|
|
const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
|
|
const cornerRadius = node.cornerRadius ?? 0.15
|
|
const archHeight = node.archHeight ?? 0.35
|
|
const openingRevealRadius = node.openingRevealRadius ?? 0.025
|
|
const maxRoundedRadius = Math.max(0.01, getMaxSharedWindowRadius(node.width, node.height))
|
|
const displayedWindowType = node.windowType === 'hopper' ? 'awning' : (node.windowType ?? 'fixed')
|
|
const awningDirection = node.windowType === 'hopper' ? 'down' : (node.awningDirection ?? 'up')
|
|
const windowType = node.windowType ?? 'fixed'
|
|
const isFixedWindow = windowType === 'fixed'
|
|
const isTrackSashWindow =
|
|
windowType === 'sliding' || windowType === 'single-hung' || windowType === 'double-hung'
|
|
const isOperableSashWindow =
|
|
windowType === 'casement' ||
|
|
windowType === 'awning' ||
|
|
windowType === 'hopper' ||
|
|
windowType === 'louvered'
|
|
const isOperableWindow = isTrackSashWindow || isOperableSashWindow
|
|
const supportsWindowShape = shapedWindowTypes.has(node.windowType ?? 'fixed')
|
|
const supportsGrid = isFixedWindow
|
|
const supportsSill = !silllessWindowTypes.has(node.windowType)
|
|
const showWindowTypeSection = !isOpening
|
|
const showWindowShapeSection = !isOpening && supportsWindowShape
|
|
const showOpeningShapeSection = isOpening
|
|
const showFrameSection = !isOpening
|
|
const showGridSection = !isOpening && supportsGrid
|
|
const showSillSection = !isOpening && supportsSill
|
|
const showOperationSection = !isOpening && isOperableWindow
|
|
const showAwningDirectionSection = !isOpening && displayedWindowType === 'awning'
|
|
const showCasementSection = !isOpening && windowType === 'casement'
|
|
const showFlipSide = !isOpening
|
|
const operationLabel = isTrackSashWindow
|
|
? windowType === 'sliding'
|
|
? 'Slide'
|
|
: 'Raise'
|
|
: windowType === 'casement'
|
|
? 'Swing'
|
|
: windowType === 'louvered'
|
|
? 'Slats'
|
|
: 'Tilt'
|
|
|
|
const setOperationState = (value: number) => {
|
|
useInteractive.getState().cancelWindowAnimation(node.id)
|
|
useInteractive.getState().removeWindowOpenState(node.id)
|
|
handleUpdate({ operationState: Math.max(0, Math.min(1, value)) })
|
|
}
|
|
|
|
const getDimensionUpdates = (updates: Partial<Pick<WindowNode, 'width' | 'height'>>) => {
|
|
const nextWidth = updates.width ?? node.width
|
|
const nextHeight = updates.height ?? node.height
|
|
const nextUpdates: Partial<WindowNode> = { ...updates }
|
|
|
|
if (openingShape === 'rounded') {
|
|
if (openingRadiusMode === 'individual') {
|
|
const currentRadii = openingCornerRadii as [number, number, number, number]
|
|
const nextRadii = normalizeWindowCornerRadii(
|
|
openingCornerRadii as [number, number, number, number],
|
|
nextWidth,
|
|
nextHeight,
|
|
)
|
|
if (!isSameRadiusTuple(currentRadii, nextRadii)) {
|
|
nextUpdates.openingCornerRadii = nextRadii
|
|
}
|
|
} else {
|
|
const nextRadius = Math.min(
|
|
Math.max(cornerRadius, 0),
|
|
getMaxSharedWindowRadius(nextWidth, nextHeight),
|
|
)
|
|
if (Math.abs(nextRadius - cornerRadius) > 1e-6) {
|
|
nextUpdates.cornerRadius = nextRadius
|
|
}
|
|
}
|
|
}
|
|
|
|
if (openingShape === 'arch') {
|
|
const nextArchHeight = Math.min(Math.max(archHeight, 0.05), Math.max(nextHeight, 0.05))
|
|
if (Math.abs(nextArchHeight - archHeight) > 1e-6) {
|
|
nextUpdates.archHeight = nextArchHeight
|
|
}
|
|
}
|
|
|
|
return nextUpdates
|
|
}
|
|
|
|
const setOpeningCornerRadius = (index: number, value: number, commit = false) => {
|
|
const next = [...openingCornerRadii] as [number, number, number, number]
|
|
next[index] = value
|
|
if (commit) {
|
|
commitWindowPreview('openingCornerRadii', next)
|
|
} else {
|
|
previewWindowUpdate('openingCornerRadii', next)
|
|
}
|
|
}
|
|
|
|
const setColumnRatio = (index: number, newVal: number) => {
|
|
const clamped = Math.max(0.05, Math.min(0.95, newVal))
|
|
const neighborIdx = index < numCols - 1 ? index + 1 : index - 1
|
|
const delta = clamped - normCols[index]!
|
|
const neighborVal = Math.max(0.05, normCols[neighborIdx]! - delta)
|
|
const newRatios = normCols.map((v, i) => {
|
|
if (i === index) return clamped
|
|
if (i === neighborIdx) return neighborVal
|
|
return v
|
|
})
|
|
handleUpdate({ columnRatios: newRatios })
|
|
}
|
|
|
|
const setRowRatio = (index: number, newVal: number) => {
|
|
const clamped = Math.max(0.05, Math.min(0.95, newVal))
|
|
const neighborIdx = index < numRows - 1 ? index + 1 : index - 1
|
|
const delta = clamped - normRows[index]!
|
|
const neighborVal = Math.max(0.05, normRows[neighborIdx]! - delta)
|
|
const newRatios = normRows.map((v, i) => {
|
|
if (i === index) return clamped
|
|
if (i === neighborIdx) return neighborVal
|
|
return v
|
|
})
|
|
handleUpdate({ rowRatios: newRatios })
|
|
}
|
|
|
|
return (
|
|
<PanelWrapper
|
|
icon="/icons/window.png"
|
|
onClose={handleClose}
|
|
title={node.name || 'Window'}
|
|
width={320}
|
|
>
|
|
{/* Presets strip */}
|
|
<div className="border-border/30 border-b px-3 pt-2.5 pb-1.5">
|
|
<PresetsPopover
|
|
isAuthenticated={adapter.isAuthenticated}
|
|
onApply={handleApplyPreset}
|
|
onDelete={(id) => adapter.deletePreset(id)}
|
|
onFetchPresets={(tab) => adapter.fetchPresets('window', tab)}
|
|
onOverwrite={handleOverwritePreset}
|
|
onRename={(id, name) => adapter.renamePreset(id, name)}
|
|
onSave={handleSavePreset}
|
|
onToggleCommunity={adapter.togglePresetCommunity}
|
|
tabs={adapter.tabs}
|
|
type="window"
|
|
>
|
|
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 font-medium text-muted-foreground text-xs transition-colors hover:bg-[#3e3e3e] hover:text-foreground">
|
|
<BookMarked className="h-3.5 w-3.5 shrink-0" />
|
|
<span>Presets</span>
|
|
</button>
|
|
</PresetsPopover>
|
|
</div>
|
|
|
|
<PanelSection title="Type">
|
|
<SegmentedControl
|
|
onChange={(value) =>
|
|
handleUpdate({
|
|
openingKind: value as WindowNode['openingKind'],
|
|
...(value === 'opening'
|
|
? {
|
|
openingShape,
|
|
openingRadiusMode,
|
|
openingCornerRadii,
|
|
cornerRadius,
|
|
archHeight,
|
|
openingRevealRadius,
|
|
}
|
|
: {}),
|
|
})
|
|
}
|
|
options={[
|
|
{ value: 'window', label: 'Window' },
|
|
{ value: 'opening', label: 'Opening' },
|
|
]}
|
|
value={node.openingKind ?? 'window'}
|
|
/>
|
|
</PanelSection>
|
|
|
|
{showWindowTypeSection && (
|
|
<PanelSection title="Window Type">
|
|
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
|
|
{windowTypeOptions.map((option) => {
|
|
const isSelected = displayedWindowType === option.value
|
|
return (
|
|
<button
|
|
className={cn(
|
|
'flex min-h-12 items-center rounded-lg border px-2.5 text-left text-xs transition-colors',
|
|
isSelected
|
|
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
|
|
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
|
|
)}
|
|
key={option.value}
|
|
onClick={() =>
|
|
handleUpdate({
|
|
windowType: option.value,
|
|
...(option.value === 'awning' ? { awningDirection } : {}),
|
|
...(!shapedWindowTypes.has(option.value)
|
|
? { openingShape: 'rectangle' }
|
|
: {}),
|
|
...(silllessWindowTypes.has(option.value) ? { sill: false } : {}),
|
|
})
|
|
}
|
|
type="button"
|
|
>
|
|
<span className="truncate font-medium">{option.label}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
{showAwningDirectionSection && (
|
|
<div className="mt-2">
|
|
<SegmentedControl
|
|
onChange={(value) =>
|
|
handleUpdate({
|
|
windowType: 'awning',
|
|
awningDirection: value as WindowNode['awningDirection'],
|
|
})
|
|
}
|
|
options={[
|
|
{ value: 'up', label: 'Up' },
|
|
{ value: 'down', label: 'Down' },
|
|
]}
|
|
value={awningDirection}
|
|
/>
|
|
</div>
|
|
)}
|
|
{showCasementSection && (
|
|
<div className="mt-2 space-y-2">
|
|
<SegmentedControl
|
|
onChange={(value) =>
|
|
handleUpdate({ casementStyle: value as WindowNode['casementStyle'] })
|
|
}
|
|
options={[
|
|
{ value: 'single', label: 'Single' },
|
|
{ value: 'french', label: 'French' },
|
|
]}
|
|
value={node.casementStyle ?? 'single'}
|
|
/>
|
|
{(node.casementStyle ?? 'single') === 'single' && (
|
|
<SegmentedControl
|
|
onChange={(value) =>
|
|
handleUpdate({ hingesSide: value as WindowNode['hingesSide'] })
|
|
}
|
|
options={[
|
|
{ value: 'left', label: 'Left' },
|
|
{ value: 'right', label: 'Right' },
|
|
]}
|
|
value={node.hingesSide ?? 'left'}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{showOperationSection && (
|
|
<div className="mt-2">
|
|
<SliderControl
|
|
label={operationLabel}
|
|
max={1}
|
|
min={0}
|
|
onChange={setOperationState}
|
|
precision={2}
|
|
restoreOnCommit={false}
|
|
step={0.05}
|
|
value={Math.round((node.operationState ?? 0) * 100) / 100}
|
|
/>
|
|
</div>
|
|
)}
|
|
</PanelSection>
|
|
)}
|
|
|
|
<PanelSection title="Position">
|
|
<SliderControl
|
|
label={
|
|
<>
|
|
X<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
|
</>
|
|
}
|
|
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
|
precision={2}
|
|
step={0.1}
|
|
unit="m"
|
|
value={Math.round(node.position[0] * 100) / 100}
|
|
/>
|
|
<SliderControl
|
|
label={
|
|
<>
|
|
Y<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
|
</>
|
|
}
|
|
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
|
|
precision={2}
|
|
step={0.1}
|
|
unit="m"
|
|
value={Math.round(node.position[1] * 100) / 100}
|
|
/>
|
|
{showFlipSide && (
|
|
<div className="px-1 pt-2 pb-1">
|
|
<ActionButton
|
|
className="w-full"
|
|
icon={<FlipHorizontal2 className="h-4 w-4" />}
|
|
label="Flip Side"
|
|
onClick={handleFlip}
|
|
/>
|
|
</div>
|
|
)}
|
|
</PanelSection>
|
|
|
|
<PanelSection title="Dimensions">
|
|
<SliderControl
|
|
label="Width"
|
|
min={0}
|
|
onChange={(v) => handleUpdate(getDimensionUpdates({ width: v }))}
|
|
precision={2}
|
|
restoreOnCommit={false}
|
|
step={0.1}
|
|
unit="m"
|
|
value={Math.round(node.width * 100) / 100}
|
|
/>
|
|
<SliderControl
|
|
label="Height"
|
|
min={0}
|
|
onChange={(v) => handleUpdate(getDimensionUpdates({ height: v }))}
|
|
precision={2}
|
|
restoreOnCommit={false}
|
|
step={0.1}
|
|
unit="m"
|
|
value={Math.round(node.height * 100) / 100}
|
|
/>
|
|
</PanelSection>
|
|
|
|
{showWindowShapeSection && (
|
|
<PanelSection title="Top Shape">
|
|
<SegmentedControl
|
|
onChange={(value) =>
|
|
handleUpdate({
|
|
openingShape: value as WindowNode['openingShape'],
|
|
...(value === 'rounded'
|
|
? {
|
|
openingRadiusMode,
|
|
openingCornerRadii,
|
|
cornerRadius: Math.min(cornerRadius, maxRoundedRadius),
|
|
openingRevealRadius,
|
|
sill: false,
|
|
}
|
|
: {}),
|
|
...(value === 'arch' ? { archHeight } : {}),
|
|
})
|
|
}
|
|
options={[
|
|
{ value: 'rectangle', label: 'Rect' },
|
|
{ value: 'rounded', label: 'Rounded' },
|
|
{ value: 'arch', label: 'Arch' },
|
|
]}
|
|
value={windowShape}
|
|
/>
|
|
{windowShape === 'rounded' && (
|
|
<div className="mt-2 flex flex-col gap-1">
|
|
<SegmentedControl
|
|
onChange={(value) =>
|
|
handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] })
|
|
}
|
|
options={[
|
|
{ value: 'all', label: 'All' },
|
|
{ value: 'individual', label: 'Individual' },
|
|
]}
|
|
value={openingRadiusMode}
|
|
/>
|
|
{openingRadiusMode === 'all' ? (
|
|
<SliderControl
|
|
label="Corner Radius"
|
|
max={maxRoundedRadius}
|
|
min={0}
|
|
onChange={(value) => previewWindowUpdate('cornerRadius', value)}
|
|
onCommit={(value) => commitWindowPreview('cornerRadius', value)}
|
|
precision={2}
|
|
step={0.05}
|
|
unit="m"
|
|
value={Math.round(cornerRadius * 100) / 100}
|
|
/>
|
|
) : (
|
|
<>
|
|
{[
|
|
['Top Left', 0],
|
|
['Top Right', 1],
|
|
['Bottom Right', 2],
|
|
['Bottom Left', 3],
|
|
].map(([label, index]) => (
|
|
<SliderControl
|
|
key={label}
|
|
label={label}
|
|
max={maxRoundedRadius}
|
|
min={0}
|
|
onChange={(value) => setOpeningCornerRadius(index as number, value)}
|
|
onCommit={(value) => setOpeningCornerRadius(index as number, value, true)}
|
|
precision={2}
|
|
step={0.05}
|
|
unit="m"
|
|
value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
<SliderControl
|
|
label="Reveal Radius"
|
|
max={0.08}
|
|
min={0}
|
|
onChange={(value) => previewWindowUpdate('openingRevealRadius', value)}
|
|
onCommit={(value) => commitWindowPreview('openingRevealRadius', value)}
|
|
precision={3}
|
|
step={0.005}
|
|
unit="m"
|
|
value={Math.round(openingRevealRadius * 1000) / 1000}
|
|
/>
|
|
</div>
|
|
)}
|
|
{windowShape === 'arch' && (
|
|
<div className="mt-2 flex flex-col gap-1">
|
|
<SliderControl
|
|
label="Arch Height"
|
|
max={Math.max(0.05, node.height)}
|
|
min={0.05}
|
|
onChange={(value) => handleUpdate({ archHeight: value })}
|
|
precision={2}
|
|
restoreOnCommit={false}
|
|
step={0.05}
|
|
unit="m"
|
|
value={Math.round(archHeight * 100) / 100}
|
|
/>
|
|
</div>
|
|
)}
|
|
</PanelSection>
|
|
)}
|
|
|
|
{showOpeningShapeSection && (
|
|
<PanelSection title="Opening Shape">
|
|
<SegmentedControl
|
|
onChange={(value) =>
|
|
handleUpdate({ openingShape: value as WindowNode['openingShape'] })
|
|
}
|
|
options={[
|
|
{ value: 'rectangle', label: 'Rect' },
|
|
{ value: 'rounded', label: 'Rounded' },
|
|
{ value: 'arch', label: 'Arch' },
|
|
]}
|
|
value={openingShape}
|
|
/>
|
|
{openingShape === 'rounded' && (
|
|
<div className="mt-2 flex flex-col gap-1">
|
|
<SegmentedControl
|
|
onChange={(value) =>
|
|
handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] })
|
|
}
|
|
options={[
|
|
{ value: 'all', label: 'All' },
|
|
{ value: 'individual', label: 'Individual' },
|
|
]}
|
|
value={openingRadiusMode}
|
|
/>
|
|
{openingRadiusMode === 'all' ? (
|
|
<SliderControl
|
|
label="Corner Radius"
|
|
max={maxRoundedRadius}
|
|
min={0}
|
|
onChange={(value) => previewWindowUpdate('cornerRadius', value)}
|
|
onCommit={(value) => commitWindowPreview('cornerRadius', value)}
|
|
precision={2}
|
|
step={0.05}
|
|
unit="m"
|
|
value={Math.round(cornerRadius * 100) / 100}
|
|
/>
|
|
) : (
|
|
<>
|
|
{[
|
|
['Top Left', 0],
|
|
['Top Right', 1],
|
|
['Bottom Right', 2],
|
|
['Bottom Left', 3],
|
|
].map(([label, index]) => (
|
|
<SliderControl
|
|
key={label}
|
|
label={label}
|
|
max={maxRoundedRadius}
|
|
min={0}
|
|
onChange={(value) => setOpeningCornerRadius(index as number, value)}
|
|
onCommit={(value) => setOpeningCornerRadius(index as number, value, true)}
|
|
precision={2}
|
|
step={0.05}
|
|
unit="m"
|
|
value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
<SliderControl
|
|
label="Reveal Radius"
|
|
max={0.08}
|
|
min={0}
|
|
onChange={(value) => previewWindowUpdate('openingRevealRadius', value)}
|
|
onCommit={(value) => commitWindowPreview('openingRevealRadius', value)}
|
|
precision={3}
|
|
step={0.005}
|
|
unit="m"
|
|
value={Math.round(openingRevealRadius * 1000) / 1000}
|
|
/>
|
|
</div>
|
|
)}
|
|
{openingShape === 'arch' && (
|
|
<div className="mt-2 flex flex-col gap-1">
|
|
<SliderControl
|
|
label="Arch Height"
|
|
max={Math.max(0.05, node.height)}
|
|
min={0.05}
|
|
onChange={(value) => handleUpdate({ archHeight: value })}
|
|
precision={2}
|
|
restoreOnCommit={false}
|
|
step={0.05}
|
|
unit="m"
|
|
value={Math.round(archHeight * 100) / 100}
|
|
/>
|
|
</div>
|
|
)}
|
|
</PanelSection>
|
|
)}
|
|
|
|
{!isOpening && (
|
|
<>
|
|
{showFrameSection && (
|
|
<PanelSection title="Frame">
|
|
<SliderControl
|
|
label="Thickness"
|
|
min={0}
|
|
onChange={(v) => handleUpdate({ frameThickness: v })}
|
|
precision={3}
|
|
step={0.01}
|
|
unit="m"
|
|
value={Math.round(node.frameThickness * 1000) / 1000}
|
|
/>
|
|
<SliderControl
|
|
label="Depth"
|
|
min={0}
|
|
onChange={(v) => handleUpdate({ frameDepth: v })}
|
|
precision={3}
|
|
step={0.01}
|
|
unit="m"
|
|
value={Math.round(node.frameDepth * 1000) / 1000}
|
|
/>
|
|
</PanelSection>
|
|
)}
|
|
|
|
{showGridSection && (
|
|
<PanelSection title="Grid">
|
|
<SliderControl
|
|
label="Columns"
|
|
max={8}
|
|
min={1}
|
|
onChange={(v) => {
|
|
const n = Math.max(1, Math.min(8, Math.round(v)))
|
|
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
|
|
}}
|
|
precision={0}
|
|
step={1}
|
|
value={numCols}
|
|
/>
|
|
<SliderControl
|
|
label="Rows"
|
|
max={8}
|
|
min={1}
|
|
onChange={(v) => {
|
|
const n = Math.max(1, Math.min(8, Math.round(v)))
|
|
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
|
|
}}
|
|
precision={0}
|
|
step={1}
|
|
value={numRows}
|
|
/>
|
|
|
|
{numCols > 1 && (
|
|
<div className="mt-2 flex flex-col gap-1">
|
|
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
|
Col Widths
|
|
</div>
|
|
{normCols.map((ratio, i) => (
|
|
<SliderControl
|
|
key={`c-${i}`}
|
|
label={`C${i + 1}`}
|
|
max={95}
|
|
min={5}
|
|
onChange={(v) => setColumnRatio(i, v / 100)}
|
|
precision={1}
|
|
step={1}
|
|
unit="%"
|
|
value={Math.round(ratio * 100 * 10) / 10}
|
|
/>
|
|
))}
|
|
<div className="mt-1 border-border/50 border-t pt-1">
|
|
<SliderControl
|
|
label="Divider"
|
|
max={0.1}
|
|
min={0.005}
|
|
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
|
|
precision={3}
|
|
step={0.01}
|
|
unit="m"
|
|
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{numRows > 1 && (
|
|
<div className="mt-2 flex flex-col gap-1">
|
|
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
|
Row Heights
|
|
</div>
|
|
{normRows.map((ratio, i) => (
|
|
<SliderControl
|
|
key={`r-${i}`}
|
|
label={`R${i + 1}`}
|
|
max={95}
|
|
min={5}
|
|
onChange={(v) => setRowRatio(i, v / 100)}
|
|
precision={1}
|
|
step={1}
|
|
unit="%"
|
|
value={Math.round(ratio * 100 * 10) / 10}
|
|
/>
|
|
))}
|
|
<div className="mt-1 border-border/50 border-t pt-1">
|
|
<SliderControl
|
|
label="Divider"
|
|
max={0.1}
|
|
min={0.005}
|
|
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
|
|
precision={3}
|
|
step={0.01}
|
|
unit="m"
|
|
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</PanelSection>
|
|
)}
|
|
|
|
{showSillSection && (
|
|
<PanelSection title="Sill">
|
|
<ToggleControl
|
|
checked={node.sill}
|
|
label="Enable Sill"
|
|
onChange={(checked) => handleUpdate({ sill: checked })}
|
|
/>
|
|
{node.sill && (
|
|
<div className="mt-1 flex flex-col gap-1">
|
|
<SliderControl
|
|
label="Depth"
|
|
min={0}
|
|
onChange={(v) => handleUpdate({ sillDepth: v })}
|
|
precision={3}
|
|
step={0.01}
|
|
unit="m"
|
|
value={Math.round(node.sillDepth * 1000) / 1000}
|
|
/>
|
|
<SliderControl
|
|
label="Thickness"
|
|
min={0}
|
|
onChange={(v) => handleUpdate({ sillThickness: v })}
|
|
precision={3}
|
|
step={0.01}
|
|
unit="m"
|
|
value={Math.round(node.sillThickness * 1000) / 1000}
|
|
/>
|
|
</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
|
|
className="hover:bg-red-500/20"
|
|
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
|
label="Delete"
|
|
onClick={handleDelete}
|
|
/>
|
|
</ActionGroup>
|
|
</PanelSection>
|
|
</PanelWrapper>
|
|
)
|
|
}
|