window panel
This commit is contained in:
@@ -280,13 +280,15 @@ export const WindowTool: React.FC = () => {
|
||||
frameDepth: draft.frameDepth,
|
||||
columnRatios: draft.columnRatios,
|
||||
rowRatios: draft.rowRatios,
|
||||
dividerThickness: draft.dividerThickness,
|
||||
columnDividerThickness: draft.columnDividerThickness,
|
||||
rowDividerThickness: draft.rowDividerThickness,
|
||||
sill: draft.sill,
|
||||
sillDepth: draft.sillDepth,
|
||||
sillThickness: draft.sillThickness,
|
||||
})
|
||||
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type AnyNode, type AnyNodeId, type WindowNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { X } from 'lucide-react'
|
||||
import { FlipHorizontal2, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useCallback } from 'react'
|
||||
import { NumberInput } from '@/components/ui/primitives/number-input'
|
||||
@@ -23,7 +23,6 @@ export function WindowPanel() {
|
||||
(updates: Partial<WindowNode>) => {
|
||||
if (!selectedId) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
// Mark dirty so window-system regenerates geometry
|
||||
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
|
||||
},
|
||||
[selectedId, updateNode],
|
||||
@@ -33,13 +32,53 @@ export function WindowPanel() {
|
||||
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])
|
||||
|
||||
if (!node || node.type !== 'window' || selectedIds.length !== 1) return null
|
||||
|
||||
const columns = node.columnRatios.length
|
||||
const rows = node.rowRatios.length
|
||||
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)
|
||||
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 (
|
||||
<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 */}
|
||||
<div className="flex items-center justify-between gap-2 border-b p-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -59,6 +98,36 @@ export function WindowPanel() {
|
||||
|
||||
{/* 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-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 */}
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
<NumberInput
|
||||
label="Width"
|
||||
value={Math.round(node.width * 1000) / 1000}
|
||||
value={Math.round(node.width * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ width: v })}
|
||||
min={0.2}
|
||||
precision={2}
|
||||
@@ -79,7 +148,7 @@ export function WindowPanel() {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Height"
|
||||
value={Math.round(node.height * 1000) / 1000}
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
onChange={(v) => handleUpdate({ height: v })}
|
||||
min={0.2}
|
||||
precision={2}
|
||||
@@ -103,6 +172,7 @@ export function WindowPanel() {
|
||||
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>
|
||||
@@ -114,6 +184,7 @@ export function WindowPanel() {
|
||||
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>
|
||||
@@ -127,47 +198,98 @@ export function WindowPanel() {
|
||||
Grid
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Columns"
|
||||
value={columns}
|
||||
value={numCols}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ columnRatios: Array(n).fill(1) })
|
||||
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
min={1}
|
||||
max={8}
|
||||
precision={0}
|
||||
className="flex-1"
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Rows"
|
||||
value={rows}
|
||||
value={numRows}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ rowRatios: Array(n).fill(1) })
|
||||
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
min={1}
|
||||
max={8}
|
||||
precision={0}
|
||||
className="flex-1"
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Column ratios */}
|
||||
{numCols > 1 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-muted-foreground text-xs">Column widths</span>
|
||||
{normCols.map((ratio, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label={`C${i + 1}`}
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
onChange={(v) => setColumnRatio(i, v / 100)}
|
||||
min={5}
|
||||
max={95}
|
||||
precision={1}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">%</span>
|
||||
</div>
|
||||
{(columns > 1 || rows > 1) && (
|
||||
))}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NumberInput
|
||||
label="Divider thickness"
|
||||
value={Math.round(node.dividerThickness * 1000) / 1000}
|
||||
onChange={(v) => handleUpdate({ dividerThickness: v })}
|
||||
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>
|
||||
|
||||
@@ -191,6 +313,7 @@ export function WindowPanel() {
|
||||
onChange={(v) => handleUpdate({ sillDepth: v })}
|
||||
min={0.01}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
@@ -202,6 +325,7 @@ export function WindowPanel() {
|
||||
onChange={(v) => handleUpdate({ sillThickness: v })}
|
||||
min={0.005}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||
|
||||
@@ -10,6 +10,7 @@ interface NumberInputProps {
|
||||
min?: number
|
||||
max?: number
|
||||
precision?: number
|
||||
step?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -20,6 +21,7 @@ export function NumberInput({
|
||||
min,
|
||||
max,
|
||||
precision = 2,
|
||||
step = 0.1,
|
||||
className = '',
|
||||
}: NumberInputProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
@@ -55,14 +57,14 @@ export function NumberInput({
|
||||
const deltaX = moveEvent.clientX - startXRef.current
|
||||
|
||||
// Determine step size based on modifier keys
|
||||
let step = 0.1 // Default
|
||||
let dragStep = step // Default from prop
|
||||
if (moveEvent.shiftKey) {
|
||||
step = 1.0 // Coarse
|
||||
dragStep = step * 10 // Coarse
|
||||
} 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 newFinalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ export const WindowNode = BaseNode.extend({
|
||||
// [1] = single pane (no division)
|
||||
columnRatios: 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: z.boolean().default(true),
|
||||
|
||||
@@ -85,7 +85,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
|
||||
const {
|
||||
width, height, frameDepth, frameThickness,
|
||||
columnRatios, rowRatios, dividerThickness,
|
||||
columnRatios, rowRatios, columnDividerThickness, rowDividerThickness,
|
||||
sill, sillDepth, sillThickness,
|
||||
} = node
|
||||
|
||||
@@ -104,8 +104,8 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
const numCols = columnRatios.length
|
||||
const numRows = rowRatios.length
|
||||
|
||||
const usableW = innerW - (numCols - 1) * dividerThickness
|
||||
const usableH = innerH - (numRows - 1) * dividerThickness
|
||||
const usableW = innerW - (numCols - 1) * columnDividerThickness
|
||||
const usableH = innerH - (numRows - 1) * rowDividerThickness
|
||||
|
||||
const colSum = columnRatios.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++) {
|
||||
colXCenters.push(cx + colWidths[c]! / 2)
|
||||
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[] = []
|
||||
let cy = -innerH / 2
|
||||
let cy = innerH / 2
|
||||
for (let r = 0; r < numRows; r++) {
|
||||
rowYCenters.push(cy + rowHeights[r]! / 2)
|
||||
cy += rowHeights[r]!
|
||||
if (r < numRows - 1) cy += dividerThickness
|
||||
rowYCenters.push(cy - rowHeights[r]! / 2)
|
||||
cy -= rowHeights[r]!
|
||||
if (r < numRows - 1) cy -= rowDividerThickness
|
||||
}
|
||||
|
||||
// Column dividers — full inner height
|
||||
cx = -innerW / 2
|
||||
for (let c = 0; c < numCols - 1; c++) {
|
||||
cx += colWidths[c]!
|
||||
addBox(mesh, frameMaterial, dividerThickness, innerH, frameDepth, cx + dividerThickness / 2, 0, 0)
|
||||
cx += dividerThickness
|
||||
addBox(mesh, frameMaterial, columnDividerThickness, innerH, frameDepth, cx + columnDividerThickness / 2, 0, 0)
|
||||
cx += columnDividerThickness
|
||||
}
|
||||
|
||||
// Row dividers — per column width, so they don't overlap column dividers
|
||||
cy = -innerH / 2
|
||||
// Row dividers — per column width, so they don't overlap column dividers (top to bottom)
|
||||
cy = innerH / 2
|
||||
for (let r = 0; r < numRows - 1; r++) {
|
||||
cy += rowHeights[r]!
|
||||
const divY = cy + dividerThickness / 2
|
||||
cy -= rowHeights[r]!
|
||||
const divY = cy - rowDividerThickness / 2
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user