2026-02-25 15:21:19 +09:00

648 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
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 { 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'
export function DoorPanel() {
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)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as DoorNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<DoorNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId, updateNode],
)
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
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!selectedId || !node) return
sfxEmitter.emit('sfx:item-delete')
deleteNode(selectedId as AnyNode['id'])
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
setSelection({ selectedIds: [] })
}, [selectedId, node, deleteNode, setSelection])
const handleDuplicate = useCallback(() => {
if (!node || !node.parentId) return
sfxEmitter.emit('sfx:item-pick')
useScene.temporal.getState().pause()
const duplicate = DoorNode.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,
frameThickness: node.frameThickness,
frameDepth: node.frameDepth,
threshold: node.threshold,
thresholdHeight: node.thresholdHeight,
hingesSide: node.hingesSide,
swingDirection: node.swingDirection,
segments: node.segments.map(s => ({ ...s, columnRatios: [...s.columnRatios] })),
handle: node.handle,
handleHeight: node.handleHeight,
handleSide: node.handleSide,
doorCloser: node.doorCloser,
panicBar: node.panicBar,
panicBarHeight: node.panicBarHeight,
metadata: { isNew: true },
})
useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
setMovingNode(duplicate)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
const numSegs = node!.segments.length
const totalH = node!.segments.reduce((sum, s) => sum + s.heightRatio, 0)
const normH = node!.segments.map(s => s.heightRatio / totalH)
const clamped = Math.max(0.05, Math.min(0.95, newVal))
const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1
const delta = clamped - normH[segIdx]!
const neighborVal = Math.max(0.05, normH[neighborIdx]! - delta)
const newRatios = normH.map((v, i) => {
if (i === segIdx) return clamped
if (i === neighborIdx) return neighborVal
return v
})
const updated = node!.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! }))
handleUpdate({ segments: updated })
}
const setSegmentColumnRatio = (segIdx: number, colIdx: number, newVal: number) => {
const seg = node!.segments[segIdx]!
const normRatios = (() => {
const sum = seg.columnRatios.reduce((a, b) => a + b, 0)
return seg.columnRatios.map(r => r / sum)
})()
const numCols = normRatios.length
const clamped = Math.max(0.05, Math.min(0.95, newVal))
const neighborIdx = colIdx < numCols - 1 ? colIdx + 1 : colIdx - 1
const delta = clamped - normRatios[colIdx]!
const neighborVal = Math.max(0.05, normRatios[neighborIdx]! - delta)
const newRatios = normRatios.map((v, i) => {
if (i === colIdx) return clamped
if (i === neighborIdx) return neighborVal
return v
})
const updated = node!.segments.map((s, idx) =>
idx === segIdx ? { ...s, columnRatios: newRatios } : s,
)
handleUpdate({ segments: updated })
}
if (!node || node.type !== 'door' || selectedIds.length !== 1) return null
const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
const normHeights = node.segments.map(seg => seg.heightRatio / hSum)
return (
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-82 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md max-h-[calc(100dvh-100px)]">
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b p-3">
<div className="flex items-center gap-2 min-w-0">
<Image src="/icons/door.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
<h2 className="font-semibold text-foreground text-sm truncate">
{node.name || `Door (${node.width}×${node.height}m)`}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
onClick={handleClose}
>
<X className="h-4 w-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Position */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Position
</label>
<div className="grid grid-cols-1 gap-2">
<NumberInput
label="X along wall"
value={Math.round(node.position[0] * 100) / 100}
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
precision={2}
/>
</div>
<button
type="button"
className="w-full flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={handleFlip}
>
<FlipHorizontal2 className="h-3.5 w-3.5" />
Flip Side
</button>
</div>
{/* Dimensions */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Dimensions
</label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5">
<NumberInput
label="Width"
value={Math.round(node.width * 100) / 100}
onChange={(v) => handleUpdate({ width: v })}
min={0.5}
precision={2}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Height"
value={Math.round(node.height * 100) / 100}
onChange={(v) => handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })}
min={1.0}
precision={2}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
</div>
{/* Frame */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Frame
</label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5">
<NumberInput
label="Thickness"
value={Math.round(node.frameThickness * 1000) / 1000}
onChange={(v) => handleUpdate({ frameThickness: v })}
min={0.01}
precision={3}
step={0.01}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Depth"
value={Math.round(node.frameDepth * 1000) / 1000}
onChange={(v) => handleUpdate({ frameDepth: v })}
min={0.01}
precision={3}
step={0.01}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
</div>
{/* Content Padding */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Content Padding
</label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5">
<NumberInput
label="Horizontal"
value={Math.round(node.contentPadding[0] * 1000) / 1000}
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
min={0}
precision={3}
step={0.005}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Vertical"
value={Math.round(node.contentPadding[1] * 1000) / 1000}
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
min={0}
precision={3}
step={0.005}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
</div>
{/* Swing */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Swing
</label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Hinges</span>
<div className="flex gap-1">
{(['left', 'right'] as const).map((side) => (
<button
key={side}
type="button"
onClick={() => handleUpdate({ hingesSide: side })}
className={`flex-1 rounded border px-2 py-1 text-xs cursor-pointer transition-colors ${
node.hingesSide === side
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-accent'
}`}
>
{side.charAt(0).toUpperCase() + side.slice(1)}
</button>
))}
</div>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Direction</span>
<div className="flex gap-1">
{(['inward', 'outward'] as const).map((dir) => (
<button
key={dir}
type="button"
onClick={() => handleUpdate({ swingDirection: dir })}
className={`flex-1 rounded border px-2 py-1 text-xs cursor-pointer transition-colors ${
node.swingDirection === dir
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-accent'
}`}
>
{dir.charAt(0).toUpperCase() + dir.slice(1)}
</button>
))}
</div>
</div>
</div>
</div>
{/* Threshold */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Threshold
</label>
<Switch
checked={node.threshold}
onCheckedChange={(checked) => handleUpdate({ threshold: checked })}
/>
</div>
{node.threshold && (
<div className="flex items-center gap-1.5">
<NumberInput
label="Height"
value={Math.round(node.thresholdHeight * 1000) / 1000}
onChange={(v) => handleUpdate({ thresholdHeight: v })}
min={0.005}
precision={3}
step={0.005}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
)}
</div>
{/* Handle */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Handle
</label>
<Switch
checked={node.handle}
onCheckedChange={(checked) => handleUpdate({ handle: checked })}
/>
</div>
{node.handle && (
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5">
<NumberInput
label="Height"
value={Math.round(node.handleHeight * 100) / 100}
onChange={(v) => handleUpdate({ handleHeight: v })}
min={0.5}
max={node.height - 0.1}
precision={2}
step={0.05}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Side</span>
<div className="flex gap-1">
{(['left', 'right'] as const).map((side) => (
<button
key={side}
type="button"
onClick={() => handleUpdate({ handleSide: side })}
className={`flex-1 rounded border px-2 py-1 text-xs cursor-pointer transition-colors ${
node.handleSide === side
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-accent'
}`}
>
{side.charAt(0).toUpperCase() + side.slice(1)}
</button>
))}
</div>
</div>
</div>
)}
</div>
{/* Hardware */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Hardware
</label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-foreground">Door Closer</span>
<Switch
checked={node.doorCloser}
onCheckedChange={(checked) => handleUpdate({ doorCloser: checked })}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-foreground">Panic Bar</span>
<Switch
checked={node.panicBar}
onCheckedChange={(checked) => handleUpdate({ panicBar: checked })}
/>
</div>
{node.panicBar && (
<div className="flex items-center gap-1.5">
<NumberInput
label="Bar height"
value={Math.round(node.panicBarHeight * 100) / 100}
onChange={(v) => handleUpdate({ panicBarHeight: v })}
min={0.5}
max={node.height - 0.1}
precision={2}
step={0.05}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
)}
</div>
</div>
{/* Segments */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Leaf segments (top → bottom)
</label>
{node.segments.map((seg, i) => {
const numCols = seg.columnRatios.length
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
const normCols = seg.columnRatios.map(r => r / colSum)
return (
<div key={i} className="rounded border border-border p-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">Segment {i + 1}</span>
<div className="flex gap-1">
{(['panel', 'glass', 'empty'] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, type: t } : s,
)
handleUpdate({ segments: updated })
}}
className={`rounded border px-1.5 py-0.5 text-xs cursor-pointer transition-colors ${
seg.type === t
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-accent'
}`}
>
{t}
</button>
))}
</div>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Height"
value={Math.round(normHeights[i]! * 100 * 10) / 10}
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
min={5}
max={95}
precision={1}
step={1}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">%</span>
</div>
{/* Columns */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Columns</span>
<NumberInput
label=""
value={numCols}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
)
handleUpdate({ segments: updated })
}}
min={1}
max={8}
precision={0}
step={1}
className="w-16"
/>
</div>
{numCols > 1 && (
<div className="space-y-1 pl-1">
{normCols.map((ratio, ci) => (
<div key={ci} className="flex items-center gap-1.5">
<NumberInput
label={`C${ci + 1}`}
value={Math.round(ratio * 100 * 10) / 10}
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
min={5}
max={95}
precision={1}
step={1}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">%</span>
</div>
))}
<div className="flex items-center gap-1.5">
<NumberInput
label="Divider"
value={Math.round(seg.dividerThickness * 1000) / 1000}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, dividerThickness: v } : s,
)
handleUpdate({ segments: updated })
}}
min={0.005}
precision={3}
step={0.005}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
)}
</div>
{seg.type === 'panel' && (
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5">
<NumberInput
label="Inset"
value={Math.round(seg.panelInset * 1000) / 1000}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelInset: v } : s,
)
handleUpdate({ segments: updated })
}}
min={0.005}
precision={3}
step={0.005}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Depth"
value={Math.round(seg.panelDepth * 1000) / 1000}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelDepth: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
)}
</div>
)
})}
<div className="flex gap-2">
<button
type="button"
className="flex-1 rounded border border-border px-2 py-1 text-xs hover:bg-accent cursor-pointer"
onClick={() => {
const updated = [
...node.segments,
{ type: 'panel' as const, heightRatio: 1, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 },
]
handleUpdate({ segments: updated })
}}
>
+ Add segment
</button>
{node.segments.length > 1 && (
<button
type="button"
className="flex-1 rounded border border-border px-2 py-1 text-xs hover:bg-accent cursor-pointer"
onClick={() => {
handleUpdate({ segments: node.segments.slice(0, -1) })
}}
>
− Remove last
</button>
)}
</div>
</div>
</div>
{/* Action Buttons */}
<div className="border-t p-3">
<div className="flex gap-2">
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={handleMove}
>
<Move className="h-3.5 w-3.5" />
<span>Move</span>
</button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={handleDuplicate}
>
<Copy className="h-3.5 w-3.5" />
<span>Duplicate</span>
</button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={handleDelete}
>
<Trash2 className="h-3.5 w-3.5" />
<span>Delete</span>
</button>
</div>
</div>
</div>
)
}