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
+25 -2
View File
@@ -10,7 +10,7 @@ import {
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { triggerSFX, useEditor } from '@pascal-app/editor'
import { consumePlacementDragRelease, triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
@@ -89,9 +89,19 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
roofSegmentId: node.roofSegmentId,
})
const clearTarget = () => {
lastTarget = null
setSegmentXform(null)
setHitLocal(null)
setPreviewSegment(null)
}
const updatePreview = (event: RoofEvent) => {
const target = roofDrag.resolve(event)
if (!target) return
if (!target) {
clearTarget()
return
}
lastTarget = target
const sx = Math.round(target.localX * 20) / 20
@@ -156,14 +166,27 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
event.stopPropagation()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
if (!lastTarget) return
onClick({
nativeEvent: event,
stopPropagation: () => event.stopPropagation(),
} as unknown as RoofEvent)
}
emitter.on('roof:move', updatePreview)
emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onClick)
emitter.on('roof:leave', clearTarget)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onClick)
emitter.off('roof:leave', clearTarget)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
}
}, [activeBuildingId, node, setMovingNode, setSelection])
+24 -12
View File
@@ -14,6 +14,7 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { chimneyDefinition } from './definition'
import ChimneyPreview from './preview'
@@ -139,19 +140,30 @@ const ChimneyTool = () => {
}
}, [activeBuildingId, setSelection])
if (!activeBuildingId || !segmentXform || !hitLocal || !previewSegment) return null
// Outer group mirrors the real renderer's `position={segment.position}
// rotation-y={segment.rotation}` chain by composing the segment's
// building-local matrix (which walks roof + level + segment). Inner
// group offsets by the cursor's segment-local x/z so the chimney
// geometry (built with `position[0,2] = 0`) lands under the cursor.
return (
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
<group position={[hitLocal[0], 0, hitLocal[2]]}>
<ChimneyPreview node={previewNode} segment={previewSegment} />
</group>
</group>
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
onInvalidTarget={() => {
setSegmentXform(null)
setHitLocal(null)
setPreviewSegment(null)
}}
size={[1, 2.5, 1]}
/>
{activeBuildingId && segmentXform && hitLocal && previewSegment && (
// Outer group mirrors the real renderer's `position={segment.position}
// rotation-y={segment.rotation}` chain by composing the segment's
// building-local matrix (which walks roof + level + segment). Inner
// group offsets by the cursor's segment-local x/z so the chimney
// geometry (built with `position[0,2] = 0`) lands under the cursor.
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
<group position={[hitLocal[0], 0, hitLocal[2]]}>
<ChimneyPreview node={previewNode} segment={previewSegment} />
</group>
</group>
)}
</>
)
}