165 lines
5.8 KiB
TypeScript
165 lines
5.8 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
type AnyNodeId,
|
|
type DormerNode,
|
|
DormerNode as DormerNodeSchema,
|
|
type RoofSegmentNode,
|
|
sceneRegistry,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import { useEditor } from '@pascal-app/editor'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { useEffect, useMemo } from 'react'
|
|
import DormerPreview from './preview'
|
|
import { useDormerPlacement } from './use-dormer-placement'
|
|
|
|
/**
|
|
* Drag-to-place tool for dormer duplicate / move. Receives the moving
|
|
* node (a clone with `id` stripped + `metadata.isNew = true` after a
|
|
* Duplicate action) via `node` prop, shows the same ghost preview as
|
|
* placement, and on click commits the cloned dormer to the hit segment.
|
|
*
|
|
* On cancel, a duplicate clone is deleted and an existing dormer is
|
|
* restored to its original segment + position. Mounted via
|
|
* `def.affordanceTools.move`.
|
|
*/
|
|
const MoveDormerTool = ({ node }: { node: DormerNode }) => {
|
|
const setSelection = useViewer((s) => s.setSelection)
|
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
|
|
// Ghost data — same as the moving clone but pinned to position[0,0,0]
|
|
// (the outer groups place it on the roof). Reparse so Zod fills any
|
|
// defaults missing from the clone.
|
|
const previewNode = useMemo(() => {
|
|
const { id: _id, ...rest } = node
|
|
return DormerNodeSchema.parse({
|
|
...rest,
|
|
position: [0, 0, 0],
|
|
rotation: 0,
|
|
})
|
|
}, [node])
|
|
|
|
// Hide the moving dormer while dragging. Restored in cleanup or on
|
|
// commit. We also mark metadata.isTransient so any other consumer
|
|
// (e.g. the inspector) can short-circuit.
|
|
const meta =
|
|
typeof node.metadata === 'object' && node.metadata !== null
|
|
? (node.metadata as Record<string, unknown>)
|
|
: {}
|
|
const isNew = !!meta.isNew
|
|
const originalRotation = node.rotation ?? 0
|
|
const originalMetadata = node.metadata
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: capture-on-mount; meta is intentionally not re-read on changes.
|
|
useEffect(() => {
|
|
if (!isNew) {
|
|
useScene.getState().updateNode(node.id as AnyNodeId, {
|
|
metadata: { ...meta, isTransient: true },
|
|
})
|
|
}
|
|
const dormerObj = sceneRegistry.nodes.get(node.id)
|
|
const prevVisible = dormerObj?.visible
|
|
if (dormerObj) dormerObj.visible = false
|
|
|
|
return () => {
|
|
// Restore visibility + metadata if the move was cancelled.
|
|
const obj = sceneRegistry.nodes.get(node.id)
|
|
if (obj) obj.visible = prevVisible ?? true
|
|
if (!isNew) {
|
|
useScene.getState().updateNode(node.id as AnyNodeId, {
|
|
metadata: originalMetadata,
|
|
})
|
|
}
|
|
}
|
|
}, [node.id, isNew])
|
|
|
|
const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({
|
|
initialRotation: originalRotation,
|
|
relativeStart: {
|
|
position: [...node.position] as [number, number, number],
|
|
roofSegmentId: node.roofSegmentId,
|
|
},
|
|
onCommit: (hit, rotation) => {
|
|
const state = useScene.getState()
|
|
|
|
// Strip the `isNew` / `isTransient` flags — only used to mark a
|
|
// clone or in-flight move that hasn't been committed yet.
|
|
const cleanedMeta = (() => {
|
|
const m =
|
|
node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata)
|
|
? (node.metadata as Record<string, unknown>)
|
|
: {}
|
|
const {
|
|
isNew: _isNew,
|
|
isTransient: _isTransient,
|
|
...rest
|
|
} = m as {
|
|
isNew?: boolean
|
|
isTransient?: boolean
|
|
}
|
|
return Object.keys(rest).length > 0 ? rest : undefined
|
|
})()
|
|
|
|
if (isNew || !node.id) {
|
|
const { id: _id, ...rest } = node
|
|
const committed = DormerNodeSchema.parse({
|
|
...rest,
|
|
roofSegmentId: hit.segment.id,
|
|
parentId: hit.segment.id,
|
|
position: [hit.localX, hit.localY, hit.localZ],
|
|
rotation,
|
|
metadata: cleanedMeta,
|
|
})
|
|
state.createNode(committed, hit.segment.id as AnyNodeId)
|
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
|
setSelection({ selectedIds: [committed.id] })
|
|
} else {
|
|
const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined
|
|
state.updateNode(node.id as AnyNodeId, {
|
|
roofSegmentId: hit.segment.id,
|
|
parentId: hit.segment.id,
|
|
position: [hit.localX, hit.localY, hit.localZ],
|
|
rotation,
|
|
metadata: cleanedMeta,
|
|
})
|
|
if (prevSegmentId) state.dirtyNodes.add(prevSegmentId)
|
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
|
// Unlist from previous segment's children and add to the new one.
|
|
if (prevSegmentId && prevSegmentId !== (hit.segment.id as AnyNodeId)) {
|
|
const prevSeg = state.nodes[prevSegmentId] as RoofSegmentNode | undefined
|
|
if (prevSeg) {
|
|
state.updateNode(prevSegmentId, {
|
|
children: (prevSeg.children ?? []).filter((id) => id !== node.id),
|
|
})
|
|
}
|
|
const newSeg = state.nodes[hit.segment.id as AnyNodeId] as RoofSegmentNode | undefined
|
|
if (newSeg && !(newSeg.children ?? []).includes(node.id)) {
|
|
state.updateNode(hit.segment.id as AnyNodeId, {
|
|
children: [...(newSeg.children ?? []), node.id],
|
|
})
|
|
}
|
|
}
|
|
setSelection({ selectedIds: [node.id] })
|
|
}
|
|
const dormerObj = sceneRegistry.nodes.get(node.id)
|
|
if (dormerObj) dormerObj.visible = true
|
|
setMovingNode(null)
|
|
},
|
|
})
|
|
|
|
if (!activeBuildingId || !segmentXform || !hitLocal) return null
|
|
|
|
return (
|
|
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
|
<group position={hitLocal}>
|
|
<group rotation-y={ghostRotation}>
|
|
<DormerPreview node={previewNode} />
|
|
</group>
|
|
</group>
|
|
</group>
|
|
)
|
|
}
|
|
|
|
export default MoveDormerTool
|