* feat: add polygon movement support to editor and implement dedicated move tools for walls, slabs, and ceilings * feat: add uv2 attribute to ceiling and slab geometries for lightmap support * Fix curved wall miters and align roof position sliders * Fix wall measurement label and extension anchors * Fix WebGPU renderer initialization order * fix(editor): scope wall movement to the selected level --------- Co-authored-by: Pascal <open@pascal.app>
490 lines
16 KiB
TypeScript
490 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
type MaterialSchema,
|
|
type StairNode,
|
|
type StairRailingMode,
|
|
type StairTopLandingMode,
|
|
type StairType,
|
|
StairNode as StairNodeSchema,
|
|
type StairSegmentNode,
|
|
StairSegmentNode as StairSegmentNodeSchema,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
|
import { useCallback } from 'react'
|
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
import useEditor from '../../../store/use-editor'
|
|
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
|
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
import { MaterialPicker } from '../controls/material-picker'
|
|
import { PanelSection } from '../controls/panel-section'
|
|
import { SegmentedControl } from '../controls/segmented-control'
|
|
import { SliderControl } from '../controls/slider-control'
|
|
import { ToggleControl } from '../controls/toggle-control'
|
|
import { PanelWrapper } from './panel-wrapper'
|
|
|
|
const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
|
|
{ label: 'None', value: 'none' },
|
|
{ label: 'Left', value: 'left' },
|
|
{ label: 'Right', value: 'right' },
|
|
{ label: 'Both', value: 'both' },
|
|
]
|
|
|
|
const STAIR_TYPE_OPTIONS: { label: string; value: StairType }[] = [
|
|
{ label: 'Straight', value: 'straight' },
|
|
{ label: 'Curved', value: 'curved' },
|
|
{ label: 'Spiral', value: 'spiral' },
|
|
]
|
|
|
|
const TOP_LANDING_MODE_OPTIONS: { label: string; value: StairTopLandingMode }[] = [
|
|
{ label: 'None', value: 'none' },
|
|
{ label: 'Integrated', value: 'integrated' },
|
|
]
|
|
|
|
export function StairPanel() {
|
|
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 createNode = useScene((s) => s.createNode)
|
|
const createNodes = useScene((s) => s.createNodes)
|
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
|
|
const selectedId = selectedIds[0]
|
|
const node = selectedId
|
|
? (nodes[selectedId as AnyNode['id']] as StairNode | undefined)
|
|
: undefined
|
|
|
|
const handleUpdate = useCallback(
|
|
(updates: Partial<StairNode>) => {
|
|
if (!selectedId) return
|
|
updateNode(selectedId as AnyNode['id'], updates)
|
|
},
|
|
[selectedId, updateNode],
|
|
)
|
|
|
|
const handleMaterialChange = useCallback(
|
|
(material: MaterialSchema) => {
|
|
handleUpdate({ material, materialPreset: undefined })
|
|
},
|
|
[handleUpdate],
|
|
)
|
|
|
|
const handleMaterialPresetChange = useCallback(
|
|
(materialPreset: string) => {
|
|
handleUpdate({ materialPreset, material: undefined })
|
|
},
|
|
[handleUpdate],
|
|
)
|
|
|
|
const handleClose = useCallback(() => {
|
|
setSelection({ selectedIds: [] })
|
|
}, [setSelection])
|
|
|
|
const getLastSegmentFillDefaults = useCallback(() => {
|
|
if (!node) return { fillToFloor: true }
|
|
const children = node.children ?? []
|
|
const lastChildId = children[children.length - 1]
|
|
if (lastChildId) {
|
|
const lastChild = nodes[lastChildId as AnyNodeId] as StairSegmentNode | undefined
|
|
if (lastChild?.type === 'stair-segment') {
|
|
return { fillToFloor: lastChild.fillToFloor }
|
|
}
|
|
}
|
|
return { fillToFloor: true }
|
|
}, [node, nodes])
|
|
|
|
const handleAddFlight = useCallback(() => {
|
|
if (!node) return
|
|
const { fillToFloor } = getLastSegmentFillDefaults()
|
|
const segment = StairSegmentNodeSchema.parse({
|
|
segmentType: 'stair',
|
|
width: 1.0,
|
|
length: 3.0,
|
|
height: 2.5,
|
|
stepCount: 10,
|
|
attachmentSide: 'front',
|
|
fillToFloor,
|
|
thickness: 0.25,
|
|
position: [0, 0, 0],
|
|
})
|
|
createNode(segment, node.id as AnyNodeId)
|
|
}, [node, createNode, getLastSegmentFillDefaults])
|
|
|
|
const handleAddLanding = useCallback(() => {
|
|
if (!node) return
|
|
const { fillToFloor } = getLastSegmentFillDefaults()
|
|
const segment = StairSegmentNodeSchema.parse({
|
|
segmentType: 'landing',
|
|
width: 1.0,
|
|
length: 1.0,
|
|
height: 0,
|
|
stepCount: 0,
|
|
attachmentSide: 'front',
|
|
fillToFloor,
|
|
thickness: 0.32,
|
|
position: [0, 0, 0],
|
|
})
|
|
createNode(segment, node.id as AnyNodeId)
|
|
}, [node, createNode, getLastSegmentFillDefaults])
|
|
|
|
const handleSelectSegment = useCallback(
|
|
(segmentId: string) => {
|
|
setSelection({ selectedIds: [segmentId as AnyNode['id']] })
|
|
},
|
|
[setSelection],
|
|
)
|
|
|
|
const handleDuplicate = useCallback(() => {
|
|
if (!node?.parentId) return
|
|
sfxEmitter.emit('sfx:item-pick')
|
|
|
|
let duplicateInfo = structuredClone(node) as any
|
|
delete duplicateInfo.id
|
|
duplicateInfo.metadata = { ...duplicateInfo.metadata }
|
|
duplicateInfo.children = []
|
|
duplicateInfo.position = [
|
|
duplicateInfo.position[0] + 1,
|
|
duplicateInfo.position[1],
|
|
duplicateInfo.position[2] + 1,
|
|
]
|
|
|
|
try {
|
|
const duplicate = StairNodeSchema.parse(duplicateInfo)
|
|
|
|
const nodesState = useScene.getState().nodes
|
|
const children = node.children || []
|
|
const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
|
|
{ node: duplicate, parentId: duplicate.parentId as AnyNodeId },
|
|
]
|
|
|
|
for (const childId of children) {
|
|
const childNode = nodesState[childId]
|
|
if (childNode && childNode.type === 'stair-segment') {
|
|
let childDuplicateInfo = structuredClone(childNode) as any
|
|
delete childDuplicateInfo.id
|
|
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
|
|
const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
|
|
createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
|
|
}
|
|
}
|
|
|
|
createNodes(createOps)
|
|
|
|
setSelection({ selectedIds: [duplicate.id as AnyNode['id']] })
|
|
} catch (e) {
|
|
console.error('Failed to duplicate stair', e)
|
|
}
|
|
}, [createNodes, node, setSelection])
|
|
|
|
const handleMove = useCallback(() => {
|
|
if (node) {
|
|
sfxEmitter.emit('sfx:item-pick')
|
|
setMovingNode(node)
|
|
setSelection({ selectedIds: [] })
|
|
}
|
|
}, [node, setMovingNode, setSelection])
|
|
|
|
const handleDelete = useCallback(() => {
|
|
if (!(selectedId && node)) return
|
|
sfxEmitter.emit('sfx:item-delete')
|
|
const parentId = node.parentId
|
|
useScene.getState().deleteNode(selectedId as AnyNodeId)
|
|
if (parentId) {
|
|
useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
|
|
}
|
|
setSelection({ selectedIds: [] })
|
|
}, [selectedId, node, setSelection])
|
|
|
|
if (!node || node.type !== 'stair' || selectedIds.length !== 1) return null
|
|
|
|
const segments = (node.children ?? [])
|
|
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
|
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
|
|
|
|
return (
|
|
<PanelWrapper
|
|
icon="/icons/stairs.png"
|
|
onClose={handleClose}
|
|
title={node.name || 'Staircase'}
|
|
width={300}
|
|
>
|
|
<PanelSection title="Type">
|
|
<SegmentedControl
|
|
onChange={(value) =>
|
|
handleUpdate(
|
|
value === 'spiral' && node.stairType !== 'spiral'
|
|
? {
|
|
stairType: value,
|
|
sweepAngle: DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE,
|
|
position: [node.position[0], 0, node.position[2]],
|
|
}
|
|
: { stairType: value },
|
|
)
|
|
}
|
|
options={STAIR_TYPE_OPTIONS}
|
|
value={node.stairType ?? 'straight'}
|
|
/>
|
|
</PanelSection>
|
|
|
|
{node.stairType === 'straight' && (
|
|
<PanelSection title="Segments">
|
|
<div className="flex flex-col gap-1">
|
|
{segments.map((seg, i) => (
|
|
<button
|
|
className="flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm transition-colors hover:bg-[#3e3e3e]"
|
|
key={seg.id}
|
|
onClick={() => handleSelectSegment(seg.id)}
|
|
type="button"
|
|
>
|
|
<span className="truncate">{seg.name || `Segment ${i + 1}`}</span>
|
|
<span className="text-muted-foreground text-xs capitalize">{seg.segmentType}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-1.5">
|
|
<ActionButton
|
|
icon={<Plus className="h-3.5 w-3.5" />}
|
|
label="Add flight"
|
|
onClick={handleAddFlight}
|
|
/>
|
|
<ActionButton
|
|
icon={<Plus className="h-3.5 w-3.5" />}
|
|
label="Add landing"
|
|
onClick={handleAddLanding}
|
|
/>
|
|
</div>
|
|
</PanelSection>
|
|
)}
|
|
|
|
{(node.stairType === 'curved' || node.stairType === 'spiral') && (
|
|
<PanelSection title="Geometry">
|
|
<MetricControl
|
|
label="Width"
|
|
max={10}
|
|
min={0.4}
|
|
onChange={(value) => handleUpdate({ width: value })}
|
|
precision={2}
|
|
step={0.05}
|
|
unit="m"
|
|
value={Math.round((node.width ?? 1) * 100) / 100}
|
|
/>
|
|
<MetricControl
|
|
label="Rise"
|
|
max={10}
|
|
min={0.2}
|
|
onChange={(value) => handleUpdate({ totalRise: value })}
|
|
precision={2}
|
|
step={0.05}
|
|
unit="m"
|
|
value={Math.round((node.totalRise ?? 2.5) * 100) / 100}
|
|
/>
|
|
<MetricControl
|
|
label="Steps"
|
|
max={32}
|
|
min={2}
|
|
onChange={(value) => handleUpdate({ stepCount: Math.max(2, Math.round(value)) })}
|
|
precision={0}
|
|
step={1}
|
|
unit=""
|
|
value={Math.max(2, Math.round(node.stepCount ?? 10))}
|
|
/>
|
|
{node.stairType !== 'spiral' && (
|
|
<ToggleControl
|
|
checked={node.fillToFloor ?? true}
|
|
label="Fit To Floor"
|
|
onChange={(checked) => handleUpdate({ fillToFloor: checked })}
|
|
/>
|
|
)}
|
|
{(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && (
|
|
<MetricControl
|
|
label="Thickness"
|
|
max={1}
|
|
min={0.02}
|
|
onChange={(value) => handleUpdate({ thickness: value })}
|
|
precision={2}
|
|
step={0.01}
|
|
unit="m"
|
|
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
|
|
/>
|
|
)}
|
|
<MetricControl
|
|
label="Inner Radius"
|
|
max={10}
|
|
min={node.stairType === 'spiral' ? 0.05 : 0.2}
|
|
onChange={(value) => handleUpdate({ innerRadius: value })}
|
|
precision={2}
|
|
step={0.05}
|
|
unit="m"
|
|
value={Math.round((node.innerRadius ?? 0.9) * 100) / 100}
|
|
/>
|
|
<SliderControl
|
|
label="Sweep"
|
|
max={node.stairType === 'spiral' ? 720 : 270}
|
|
min={node.stairType === 'spiral' ? -720 : -270}
|
|
onChange={(degrees) => handleUpdate({ sweepAngle: (degrees * Math.PI) / 180 })}
|
|
precision={0}
|
|
step={1}
|
|
unit="°"
|
|
value={Math.round(((node.sweepAngle ?? Math.PI / 2) * 180) / Math.PI)}
|
|
/>
|
|
{node.stairType === 'spiral' && (
|
|
<>
|
|
<SegmentedControl
|
|
onChange={(value) => handleUpdate({ topLandingMode: value })}
|
|
options={TOP_LANDING_MODE_OPTIONS}
|
|
value={node.topLandingMode ?? 'none'}
|
|
/>
|
|
{(node.topLandingMode ?? 'none') === 'integrated' && (
|
|
<MetricControl
|
|
label="Top Landing"
|
|
max={5}
|
|
min={0.3}
|
|
onChange={(value) => handleUpdate({ topLandingDepth: value })}
|
|
precision={2}
|
|
step={0.05}
|
|
unit="m"
|
|
value={Math.round((node.topLandingDepth ?? 0.9) * 100) / 100}
|
|
/>
|
|
)}
|
|
<ToggleControl
|
|
checked={node.showCenterColumn ?? true}
|
|
label="Center Column"
|
|
onChange={(checked) => handleUpdate({ showCenterColumn: checked })}
|
|
/>
|
|
<ToggleControl
|
|
checked={node.showStepSupports ?? true}
|
|
label="Step Supports"
|
|
onChange={(checked) => handleUpdate({ showStepSupports: checked })}
|
|
/>
|
|
</>
|
|
)}
|
|
</PanelSection>
|
|
)}
|
|
|
|
<PanelSection title="Position">
|
|
<SliderControl
|
|
label="X"
|
|
max={50}
|
|
min={-50}
|
|
onChange={(v) => {
|
|
const pos = [...node.position] as [number, number, number]
|
|
pos[0] = v
|
|
handleUpdate({ position: pos })
|
|
}}
|
|
precision={2}
|
|
step={0.05}
|
|
unit="m"
|
|
value={Math.round(node.position[0] * 100) / 100}
|
|
/>
|
|
<SliderControl
|
|
label="Y"
|
|
max={50}
|
|
min={-50}
|
|
onChange={(v) => {
|
|
const pos = [...node.position] as [number, number, number]
|
|
pos[1] = v
|
|
handleUpdate({ position: pos })
|
|
}}
|
|
precision={2}
|
|
step={0.05}
|
|
unit="m"
|
|
value={Math.round(node.position[1] * 100) / 100}
|
|
/>
|
|
<SliderControl
|
|
label="Z"
|
|
max={50}
|
|
min={-50}
|
|
onChange={(v) => {
|
|
const pos = [...node.position] as [number, number, number]
|
|
pos[2] = v
|
|
handleUpdate({ position: pos })
|
|
}}
|
|
precision={2}
|
|
step={0.05}
|
|
unit="m"
|
|
value={Math.round(node.position[2] * 100) / 100}
|
|
/>
|
|
<SliderControl
|
|
label="Rotation"
|
|
max={180}
|
|
min={-180}
|
|
onChange={(degrees) => {
|
|
handleUpdate({ rotation: (degrees * Math.PI) / 180 })
|
|
}}
|
|
precision={0}
|
|
step={1}
|
|
unit="°"
|
|
value={Math.round((node.rotation * 180) / Math.PI)}
|
|
/>
|
|
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
|
<ActionButton
|
|
label="-45°"
|
|
onClick={() => {
|
|
sfxEmitter.emit('sfx:item-rotate')
|
|
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
|
|
}}
|
|
/>
|
|
<ActionButton
|
|
label="+45°"
|
|
onClick={() => {
|
|
sfxEmitter.emit('sfx:item-rotate')
|
|
handleUpdate({ rotation: node.rotation + Math.PI / 4 })
|
|
}}
|
|
/>
|
|
</div>
|
|
</PanelSection>
|
|
|
|
<PanelSection title="Railing">
|
|
<SegmentedControl
|
|
onChange={(value) => handleUpdate({ railingMode: value })}
|
|
options={RAILING_MODE_OPTIONS}
|
|
value={node.railingMode ?? 'none'}
|
|
/>
|
|
{(node.railingMode ?? 'none') !== 'none' && (
|
|
<SliderControl
|
|
label="Height"
|
|
max={1.4}
|
|
min={0.7}
|
|
onChange={(value) => handleUpdate({ railingHeight: value })}
|
|
precision={2}
|
|
step={0.02}
|
|
unit="m"
|
|
value={Math.round((node.railingHeight ?? 0.92) * 100) / 100}
|
|
/>
|
|
)}
|
|
</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>
|
|
<PanelSection title="Material">
|
|
<MaterialPicker
|
|
nodeType="stair"
|
|
onChange={handleMaterialChange}
|
|
onSelectMaterialPreset={handleMaterialPresetChange}
|
|
selectedMaterialPreset={node.materialPreset}
|
|
value={node.material}
|
|
/>
|
|
</PanelSection>
|
|
</PanelWrapper>
|
|
)
|
|
}
|