window panel

This commit is contained in:
wass08
2026-02-18 13:14:55 +09:00
parent 076faae7df
commit be013d26e5
5 changed files with 198 additions and 69 deletions
@@ -280,13 +280,15 @@ export const WindowTool: React.FC = () => {
frameDepth: draft.frameDepth, frameDepth: draft.frameDepth,
columnRatios: draft.columnRatios, columnRatios: draft.columnRatios,
rowRatios: draft.rowRatios, rowRatios: draft.rowRatios,
dividerThickness: draft.dividerThickness, columnDividerThickness: draft.columnDividerThickness,
rowDividerThickness: draft.rowDividerThickness,
sill: draft.sill, sill: draft.sill,
sillDepth: draft.sillDepth, sillDepth: draft.sillDepth,
sillThickness: draft.sillThickness, sillThickness: draft.sillThickness,
}) })
useScene.getState().createNode(node, event.node.id as AnyNodeId) useScene.getState().createNode(node, event.node.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
event.stopPropagation() event.stopPropagation()
+170 -46
View File
@@ -2,7 +2,7 @@
import { type AnyNode, type AnyNodeId, type WindowNode, useScene } from '@pascal-app/core' import { type AnyNode, type AnyNodeId, type WindowNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { X } from 'lucide-react' import { FlipHorizontal2, X } from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import { useCallback } from 'react' import { useCallback } from 'react'
import { NumberInput } from '@/components/ui/primitives/number-input' import { NumberInput } from '@/components/ui/primitives/number-input'
@@ -23,7 +23,6 @@ export function WindowPanel() {
(updates: Partial<WindowNode>) => { (updates: Partial<WindowNode>) => {
if (!selectedId) return if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates) updateNode(selectedId as AnyNode['id'], updates)
// Mark dirty so window-system regenerates geometry
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
}, },
[selectedId, updateNode], [selectedId, updateNode],
@@ -33,13 +32,53 @@ export function WindowPanel() {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [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])
if (!node || node.type !== 'window' || selectedIds.length !== 1) return null if (!node || node.type !== 'window' || selectedIds.length !== 1) return null
const columns = node.columnRatios.length const numCols = node.columnRatios.length
const rows = node.rowRatios.length const numRows = node.rowRatios.length
// Normalized ratios (always sum to 1 for display)
const colSum = node.columnRatios.reduce((a, b) => a + b, 0)
const 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 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 ( return (
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-72 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md"> <div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-82 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between gap-2 border-b p-3"> <div className="flex items-center justify-between gap-2 border-b p-3">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
@@ -59,6 +98,36 @@ export function WindowPanel() {
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-4"> <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-2 gap-2">
<NumberInput
label="X"
value={Math.round(node.position[0] * 100) / 100}
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
precision={2}
/>
<NumberInput
label="Y"
value={Math.round(node.position[1] * 100) / 100}
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
precision={2}
/>
</div>
<button
type="button"
className="w-full flex items-center justify-center gap-1.5 rounded 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 */} {/* Dimensions */}
<div className="space-y-2"> <div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide"> <label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
@@ -68,7 +137,7 @@ export function WindowPanel() {
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<NumberInput <NumberInput
label="Width" label="Width"
value={Math.round(node.width * 1000) / 1000} value={Math.round(node.width * 100) / 100}
onChange={(v) => handleUpdate({ width: v })} onChange={(v) => handleUpdate({ width: v })}
min={0.2} min={0.2}
precision={2} precision={2}
@@ -79,7 +148,7 @@ export function WindowPanel() {
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<NumberInput <NumberInput
label="Height" label="Height"
value={Math.round(node.height * 1000) / 1000} value={Math.round(node.height * 100) / 100}
onChange={(v) => handleUpdate({ height: v })} onChange={(v) => handleUpdate({ height: v })}
min={0.2} min={0.2}
precision={2} precision={2}
@@ -103,6 +172,7 @@ export function WindowPanel() {
onChange={(v) => handleUpdate({ frameThickness: v })} onChange={(v) => handleUpdate({ frameThickness: v })}
min={0.01} min={0.01}
precision={3} precision={3}
step={0.01}
className="flex-1" className="flex-1"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> <span className="text-muted-foreground text-xs shrink-0">m</span>
@@ -114,6 +184,7 @@ export function WindowPanel() {
onChange={(v) => handleUpdate({ frameDepth: v })} onChange={(v) => handleUpdate({ frameDepth: v })}
min={0.01} min={0.01}
precision={3} precision={3}
step={0.01}
className="flex-1" className="flex-1"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> <span className="text-muted-foreground text-xs shrink-0">m</span>
@@ -127,46 +198,97 @@ export function WindowPanel() {
Grid Grid
</label> </label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5"> <NumberInput
<NumberInput label="Columns"
label="Columns" value={numCols}
value={columns} onChange={(v) => {
onChange={(v) => { const n = Math.max(1, Math.min(8, Math.round(v)))
const n = Math.max(1, Math.min(8, Math.round(v))) handleUpdate({ columnRatios: Array(n).fill(1 / n) })
handleUpdate({ columnRatios: Array(n).fill(1) }) }}
}} min={1}
min={1} max={8}
max={8} precision={0}
precision={0} step={1}
className="flex-1" />
/> <NumberInput
</div> label="Rows"
<div className="flex items-center gap-1.5"> value={numRows}
<NumberInput onChange={(v) => {
label="Rows" const n = Math.max(1, Math.min(8, Math.round(v)))
value={rows} handleUpdate({ rowRatios: Array(n).fill(1 / n) })
onChange={(v) => { }}
const n = Math.max(1, Math.min(8, Math.round(v))) min={1}
handleUpdate({ rowRatios: Array(n).fill(1) }) max={8}
}} precision={0}
min={1} step={1}
max={8} />
precision={0}
className="flex-1"
/>
</div>
</div> </div>
{(columns > 1 || rows > 1) && (
<div className="flex items-center gap-1.5"> {/* Column ratios */}
<NumberInput {numCols > 1 && (
label="Divider thickness" <div className="space-y-1">
value={Math.round(node.dividerThickness * 1000) / 1000} <span className="text-muted-foreground text-xs">Column widths</span>
onChange={(v) => handleUpdate({ dividerThickness: v })} {normCols.map((ratio, i) => (
min={0.005} <div key={i} className="flex items-center gap-1.5">
precision={3} <NumberInput
className="flex-1" label={`C${i + 1}`}
/> value={Math.round(ratio * 100 * 10) / 10}
<span className="text-muted-foreground text-xs shrink-0">m</span> onChange={(v) => setColumnRatio(i, v / 100)}
min={5}
max={95}
precision={1}
step={1}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">%</span>
</div>
))}
<div className="flex items-center gap-1.5">
<NumberInput
label="Col divider"
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
min={0.005}
precision={3}
step={0.01}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
)}
{/* Row ratios */}
{numRows > 1 && (
<div className="space-y-1">
<span className="text-muted-foreground text-xs">Row heights</span>
{normRows.map((ratio, i) => (
<div key={i} className="flex items-center gap-1.5">
<NumberInput
label={`R${i + 1}`}
value={Math.round(ratio * 100 * 10) / 10}
onChange={(v) => setRowRatio(i, v / 100)}
min={5}
max={95}
precision={1}
step={1}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">%</span>
</div>
))}
<div className="flex items-center gap-1.5">
<NumberInput
label="Row divider"
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
min={0.005}
precision={3}
step={0.01}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div> </div>
)} )}
</div> </div>
@@ -191,6 +313,7 @@ export function WindowPanel() {
onChange={(v) => handleUpdate({ sillDepth: v })} onChange={(v) => handleUpdate({ sillDepth: v })}
min={0.01} min={0.01}
precision={3} precision={3}
step={0.01}
className="flex-1" className="flex-1"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> <span className="text-muted-foreground text-xs shrink-0">m</span>
@@ -202,6 +325,7 @@ export function WindowPanel() {
onChange={(v) => handleUpdate({ sillThickness: v })} onChange={(v) => handleUpdate({ sillThickness: v })}
min={0.005} min={0.005}
precision={3} precision={3}
step={0.01}
className="flex-1" className="flex-1"
/> />
<span className="text-muted-foreground text-xs shrink-0">m</span> <span className="text-muted-foreground text-xs shrink-0">m</span>
@@ -10,6 +10,7 @@ interface NumberInputProps {
min?: number min?: number
max?: number max?: number
precision?: number precision?: number
step?: number
className?: string className?: string
} }
@@ -20,6 +21,7 @@ export function NumberInput({
min, min,
max, max,
precision = 2, precision = 2,
step = 0.1,
className = '', className = '',
}: NumberInputProps) { }: NumberInputProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
@@ -55,14 +57,14 @@ export function NumberInput({
const deltaX = moveEvent.clientX - startXRef.current const deltaX = moveEvent.clientX - startXRef.current
// Determine step size based on modifier keys // Determine step size based on modifier keys
let step = 0.1 // Default let dragStep = step // Default from prop
if (moveEvent.shiftKey) { if (moveEvent.shiftKey) {
step = 1.0 // Coarse dragStep = step * 10 // Coarse
} else if (moveEvent.altKey) { } else if (moveEvent.altKey) {
step = 0.01 // Fine dragStep = step * 0.1 // Fine
} }
const deltaValue = deltaX * step const deltaValue = deltaX * dragStep
const newValue = clamp(startValueRef.current + deltaValue) const newValue = clamp(startValueRef.current + deltaValue)
const newFinalValue = Number.parseFloat(newValue.toFixed(precision)) const newFinalValue = Number.parseFloat(newValue.toFixed(precision))
+2 -1
View File
@@ -28,7 +28,8 @@ export const WindowNode = BaseNode.extend({
// [1] = single pane (no division) // [1] = single pane (no division)
columnRatios: z.array(z.number()).default([1]), columnRatios: z.array(z.number()).default([1]),
rowRatios: z.array(z.number()).default([1]), rowRatios: z.array(z.number()).default([1]),
dividerThickness: z.number().default(0.03), columnDividerThickness: z.number().default(0.03),
rowDividerThickness: z.number().default(0.03),
// Sill // Sill
sill: z.boolean().default(true), sill: z.boolean().default(true),
@@ -85,7 +85,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
const { const {
width, height, frameDepth, frameThickness, width, height, frameDepth, frameThickness,
columnRatios, rowRatios, dividerThickness, columnRatios, rowRatios, columnDividerThickness, rowDividerThickness,
sill, sillDepth, sillThickness, sill, sillDepth, sillThickness,
} = node } = node
@@ -104,8 +104,8 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
const numCols = columnRatios.length const numCols = columnRatios.length
const numRows = rowRatios.length const numRows = rowRatios.length
const usableW = innerW - (numCols - 1) * dividerThickness const usableW = innerW - (numCols - 1) * columnDividerThickness
const usableH = innerH - (numRows - 1) * dividerThickness const usableH = innerH - (numRows - 1) * rowDividerThickness
const colSum = columnRatios.reduce((a, b) => a + b, 0) const colSum = columnRatios.reduce((a, b) => a + b, 0)
const rowSum = rowRatios.reduce((a, b) => a + b, 0) const rowSum = rowRatios.reduce((a, b) => a + b, 0)
@@ -118,35 +118,35 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
for (let c = 0; c < numCols; c++) { for (let c = 0; c < numCols; c++) {
colXCenters.push(cx + colWidths[c]! / 2) colXCenters.push(cx + colWidths[c]! / 2)
cx += colWidths[c]! cx += colWidths[c]!
if (c < numCols - 1) cx += dividerThickness if (c < numCols - 1) cx += columnDividerThickness
} }
// Compute row y-centers starting from bottom edge of inner area // Compute row y-centers starting from top edge of inner area (R1 = top)
const rowYCenters: number[] = [] const rowYCenters: number[] = []
let cy = -innerH / 2 let cy = innerH / 2
for (let r = 0; r < numRows; r++) { for (let r = 0; r < numRows; r++) {
rowYCenters.push(cy + rowHeights[r]! / 2) rowYCenters.push(cy - rowHeights[r]! / 2)
cy += rowHeights[r]! cy -= rowHeights[r]!
if (r < numRows - 1) cy += dividerThickness if (r < numRows - 1) cy -= rowDividerThickness
} }
// Column dividers — full inner height // Column dividers — full inner height
cx = -innerW / 2 cx = -innerW / 2
for (let c = 0; c < numCols - 1; c++) { for (let c = 0; c < numCols - 1; c++) {
cx += colWidths[c]! cx += colWidths[c]!
addBox(mesh, frameMaterial, dividerThickness, innerH, frameDepth, cx + dividerThickness / 2, 0, 0) addBox(mesh, frameMaterial, columnDividerThickness, innerH, frameDepth, cx + columnDividerThickness / 2, 0, 0)
cx += dividerThickness cx += columnDividerThickness
} }
// Row dividers — per column width, so they don't overlap column dividers // Row dividers — per column width, so they don't overlap column dividers (top to bottom)
cy = -innerH / 2 cy = innerH / 2
for (let r = 0; r < numRows - 1; r++) { for (let r = 0; r < numRows - 1; r++) {
cy += rowHeights[r]! cy -= rowHeights[r]!
const divY = cy + dividerThickness / 2 const divY = cy - rowDividerThickness / 2
for (let c = 0; c < numCols; c++) { for (let c = 0; c < numCols; c++) {
addBox(mesh, frameMaterial, colWidths[c]!, dividerThickness, frameDepth, colXCenters[c]!, divY, 0) addBox(mesh, frameMaterial, colWidths[c]!, rowDividerThickness, frameDepth, colXCenters[c]!, divY, 0)
} }
cy += dividerThickness cy -= rowDividerThickness
} }
// Glass panes // Glass panes