fix(editor): improve guided manipulation and snap affordances

This commit is contained in:
Aymeric Rabot
2026-06-11 23:59:15 -04:00
committed by GitHub
parent aab48e053f
commit 5411f5abc8
89 changed files with 2830 additions and 518 deletions
+36 -27
View File
@@ -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,