fix(editor): improve guided manipulation and snap affordances
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { type AnyNodeId, DormerNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useMemo } from 'react'
|
||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||
import { dormerDefinition } from './definition'
|
||||
import DormerPreview from './preview'
|
||||
import { useDormerPlacement } from './use-dormer-placement'
|
||||
@@ -47,36 +48,44 @@ const DormerTool = () => {
|
||||
[],
|
||||
)
|
||||
|
||||
const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({
|
||||
onCommit: (hit, rotation) => {
|
||||
const state = useScene.getState()
|
||||
const dormer = DormerNode.parse({
|
||||
...dormerDefinition.defaults(),
|
||||
name: `Dormer ${nextDormerNumber(state.nodes)}`,
|
||||
roofSegmentId: hit.segment.id,
|
||||
parentId: hit.segment.id,
|
||||
// Anchor at the slope height so the renderer matches the ghost.
|
||||
// The CSG still carves cleanly because it inverts T(position)
|
||||
// when bringing the host into dormer-local.
|
||||
position: [hit.localX, hit.localY, hit.localZ],
|
||||
rotation,
|
||||
})
|
||||
state.createNode(dormer, hit.segment.id as AnyNodeId)
|
||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||
setSelection({ selectedIds: [dormer.id] })
|
||||
},
|
||||
})
|
||||
|
||||
if (!activeBuildingId || !segmentXform || !hitLocal) return null
|
||||
const { activeBuildingId, clearPreview, segmentXform, hitLocal, ghostRotation } =
|
||||
useDormerPlacement({
|
||||
onCommit: (hit, rotation) => {
|
||||
const state = useScene.getState()
|
||||
const dormer = DormerNode.parse({
|
||||
...dormerDefinition.defaults(),
|
||||
name: `Dormer ${nextDormerNumber(state.nodes)}`,
|
||||
roofSegmentId: hit.segment.id,
|
||||
parentId: hit.segment.id,
|
||||
// Anchor at the slope height so the renderer matches the ghost.
|
||||
// The CSG still carves cleanly because it inverts T(position)
|
||||
// when bringing the host into dormer-local.
|
||||
position: [hit.localX, hit.localY, hit.localZ],
|
||||
rotation,
|
||||
})
|
||||
state.createNode(dormer, hit.segment.id as AnyNodeId)
|
||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||
setSelection({ selectedIds: [dormer.id] })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
||||
<group position={hitLocal}>
|
||||
<group rotation-y={ghostRotation}>
|
||||
<DormerPreview node={previewNode} />
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
onInvalidTarget={clearPreview}
|
||||
size={[1.8, 1.8, 1.4]}
|
||||
/>
|
||||
{activeBuildingId && segmentXform && hitLocal && (
|
||||
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
||||
<group position={hitLocal}>
|
||||
<group rotation-y={ghostRotation}>
|
||||
<DormerPreview node={previewNode} />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
} from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
import { consumePlacementDragRelease, triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
@@ -58,6 +58,7 @@ export function useDormerPlacement(opts: {
|
||||
onCommit: (hit: DormerPlacementHit, rotation: number) => void
|
||||
}): {
|
||||
activeBuildingId: string | undefined
|
||||
clearPreview: () => void
|
||||
segmentXform: DormerSegmentTransform | null
|
||||
hitLocal: [number, number, number] | null
|
||||
ghostRotation: number
|
||||
@@ -78,6 +79,11 @@ export function useDormerPlacement(opts: {
|
||||
const onCommitRef = useRef(opts.onCommit)
|
||||
onCommitRef.current = opts.onCommit
|
||||
|
||||
const clearPreview = () => {
|
||||
setSegmentXform(null)
|
||||
setHitLocal(null)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeBuildingId) return
|
||||
|
||||
@@ -99,6 +105,7 @@ export function useDormerPlacement(opts: {
|
||||
const roofDrag = relativeStartRef.current
|
||||
? createRelativeRoofDrag(relativeStartRef.current)
|
||||
: null
|
||||
let committed = false
|
||||
let lastRelativeHit: DormerPlacementHit | null = null
|
||||
|
||||
const resolvePlacementHit = (event: RoofEvent): DormerPlacementHit | null => {
|
||||
@@ -139,15 +146,27 @@ export function useDormerPlacement(opts: {
|
||||
}
|
||||
|
||||
const onClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const hit = roofDrag
|
||||
? (lastRelativeHit ?? resolvePlacementHit(event))
|
||||
: resolvePlacementHit(event)
|
||||
if (!hit) return
|
||||
committed = true
|
||||
onCommitRef.current(hit, ghostRotationRef.current)
|
||||
triggerSFX('sfx:item-place')
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (committed) return
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
const hit = roofDrag ? lastRelativeHit : null
|
||||
if (!hit) return
|
||||
committed = true
|
||||
onCommitRef.current(hit, ghostRotationRef.current)
|
||||
triggerSFX('sfx:item-place')
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'r' && e.key !== 'R') return
|
||||
const target = e.target as HTMLElement | null
|
||||
@@ -166,17 +185,20 @@ export function useDormerPlacement(opts: {
|
||||
emitter.on('roof:enter', updatePreview)
|
||||
emitter.on('roof:click', onClick)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
emitter.off('roof:move', updatePreview)
|
||||
emitter.off('roof:enter', updatePreview)
|
||||
emitter.off('roof:click', onClick)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
}
|
||||
}, [activeBuildingId])
|
||||
|
||||
return {
|
||||
activeBuildingId: activeBuildingId ?? undefined,
|
||||
clearPreview,
|
||||
segmentXform,
|
||||
hitLocal,
|
||||
ghostRotation,
|
||||
|
||||
Reference in New Issue
Block a user