Improve editor manipulation flows
This commit is contained in:
@@ -10,10 +10,11 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
applyFloorplanAlignment,
|
||||
snapPointToGrid,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
|
||||
/**
|
||||
* 2D floor-plan move handler for column — mirrors `itemFloorplanMoveTarget`:
|
||||
@@ -39,12 +40,14 @@ import {
|
||||
* Column stores rotation as a scalar (not a tuple); position is `[x, y, z]`.
|
||||
*/
|
||||
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ node, nodes }) => {
|
||||
const columnId = node.id as AnyNodeId
|
||||
const originalPosition: [number, number, number] = [...node.position] as [number, number, number]
|
||||
const rotationY = node.rotation ?? 0
|
||||
const resolveCursor = createFloorplanCursorResolver({
|
||||
original: [originalPosition[0], originalPosition[2]],
|
||||
metadata: node.metadata,
|
||||
})
|
||||
let lastPosition: [number, number, number] = originalPosition
|
||||
let lastSnapKey: string | null = null
|
||||
|
||||
@@ -54,9 +57,12 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ nod
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [columnId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const gridSnapped: WallPlanPoint = modifiers.shiftKey
|
||||
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
|
||||
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
|
||||
const snap = (value: number) => {
|
||||
if (modifiers.shiftKey) return value
|
||||
const step = useEditor.getState().gridSnapStep
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint
|
||||
// Figma-style alignment layered on the grid snap (Alt bypasses).
|
||||
const { point: snapped } = applyFloorplanAlignment(
|
||||
gridSnapped,
|
||||
|
||||
@@ -16,12 +16,17 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
commitFreshPlacementSubtree,
|
||||
DragBoundingBox,
|
||||
getFloorStackPreviewPosition,
|
||||
markToolCancelConsumed,
|
||||
resolvePlanarCursorPosition,
|
||||
stripPlacementMetadataFlags,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
useFreshPlacementVisibility,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
@@ -54,6 +59,8 @@ const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
|
||||
const [previewRotation, setPreviewRotation] = useState<number>(node.rotation)
|
||||
const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } =
|
||||
useFreshPlacementVisibility({ node })
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
@@ -71,11 +78,7 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
// Latest previewed position, so an R/T press can re-apply at the spot.
|
||||
let lastPosition: [number, number, number] = node.position
|
||||
let dragAnchor: [number, number] | null = null
|
||||
const meta =
|
||||
typeof node.metadata === 'object' && node.metadata !== null
|
||||
? (node.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
const isNew = isFreshPlacement
|
||||
const getVisualPosition = (
|
||||
position: [number, number, number],
|
||||
rotation = rotationY,
|
||||
@@ -114,9 +117,17 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
hasMoved = true
|
||||
const rawX = event.localPosition[0]
|
||||
const rawZ = event.localPosition[2]
|
||||
dragAnchor ??= [rawX, rawZ]
|
||||
let x = node.position[0] + snapToGridStep(rawX - dragAnchor[0])
|
||||
let z = node.position[2] + snapToGridStep(rawZ - dragAnchor[1])
|
||||
revealFreshPlacement()
|
||||
|
||||
const resolved = resolvePlanarCursorPosition({
|
||||
cursor: [rawX, rawZ],
|
||||
original: [node.position[0], node.position[2]],
|
||||
anchor: dragAnchor,
|
||||
mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
|
||||
snap: snapToGridStep,
|
||||
})
|
||||
dragAnchor = resolved.anchor
|
||||
let [x, z] = resolved.point
|
||||
|
||||
// Figma-style alignment snap on top of grid snap; Alt bypasses. The
|
||||
// guide connects to the candidate's nearest real anchor (resolver
|
||||
@@ -161,13 +172,30 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
// click to the grid.
|
||||
const position: [number, number, number] = [...lastPosition]
|
||||
const nodeId = (node as { id?: ColumnNode['id'] }).id
|
||||
let committedId = node.id as AnyNodeId
|
||||
|
||||
if (nodeId && useScene.getState().nodes[nodeId]) {
|
||||
committed = true
|
||||
useScene.temporal.getState().resume()
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(nodeId, { position, rotation: rotationY, ...(isNew ? { metadata: {} } : {}) })
|
||||
const data = {
|
||||
position,
|
||||
rotation: rotationY,
|
||||
...(isNew
|
||||
? {
|
||||
metadata: stripPlacementMetadataFlags(node.metadata) as ColumnNode['metadata'],
|
||||
visible: true,
|
||||
}
|
||||
: null),
|
||||
}
|
||||
if (isNew) {
|
||||
const finalId = commitFreshPlacementSubtree(nodeId as AnyNodeId, data)
|
||||
if (finalId) {
|
||||
committed = true
|
||||
committedId = finalId
|
||||
}
|
||||
} else {
|
||||
committed = true
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(nodeId, data)
|
||||
}
|
||||
useLiveTransforms.getState().clear(nodeId)
|
||||
const m = sceneRegistry.nodes.get(nodeId)
|
||||
if (m) {
|
||||
@@ -188,7 +216,11 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
}
|
||||
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
if (isNew && committed) {
|
||||
useViewer.getState().setSelection({ selectedIds: [committedId] })
|
||||
}
|
||||
triggerSFX('sfx:item-place')
|
||||
useEditor.getState().setMovingNodeOrigin('3d')
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
@@ -196,12 +228,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
const onCancel = () => {
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
useAlignmentGuides.getState().clear()
|
||||
const m = sceneRegistry.nodes.get(node.id)
|
||||
if (m) {
|
||||
m.position.set(...getVisualPosition(node.position, node.rotation))
|
||||
m.rotation.y = node.rotation
|
||||
if (isNew) {
|
||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||
} else {
|
||||
const m = sceneRegistry.nodes.get(node.id)
|
||||
if (m) {
|
||||
m.position.set(...getVisualPosition(node.position, node.rotation))
|
||||
m.rotation.y = node.rotation
|
||||
}
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
}
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
@@ -219,17 +255,20 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
useAlignmentGuides.getState().clear()
|
||||
if (!committed) {
|
||||
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
|
||||
if (!(committed || isNew || finalisedBy2D)) {
|
||||
const m = sceneRegistry.nodes.get(node.id)
|
||||
if (m) {
|
||||
m.position.set(...getVisualPosition(node.position, node.rotation))
|
||||
m.rotation.y = node.rotation
|
||||
}
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}, [exitMoveMode, node])
|
||||
}, [exitMoveMode, isFreshPlacement, node, revealFreshPlacement, useAbsoluteCursorPlacement])
|
||||
|
||||
if (!previewVisible) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -7,24 +7,27 @@ import {
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
movingFootprintAnchors,
|
||||
resolveAlignment,
|
||||
snapPointToGrid,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { getFloorStackPreviewPosition, triggerSFX, usePlacementPreview } from '@pascal-app/editor'
|
||||
import {
|
||||
getFloorStackPreviewPosition,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
usePlacementPreview,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Group } from 'three'
|
||||
import {
|
||||
type FloorPlacementClickTriggerEvent,
|
||||
getLevelLocalSnappedPosition,
|
||||
resolveAlignedFloorPlacement,
|
||||
stopPlacementCommitPropagation,
|
||||
subscribeFloorPlacementClicks,
|
||||
} from '../shared/floor-placement'
|
||||
import { ColumnPreview } from './renderer'
|
||||
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
/** Figma-style alignment-snap threshold (meters), matching the move tools and
|
||||
* the shelf placement tool. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId
|
||||
|
||||
function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) {
|
||||
@@ -52,6 +55,8 @@ const ColumnTool = () => {
|
||||
const activeLevelId = useViewer((state) => state.selection.levelId)
|
||||
const cursorRef = useRef<Group>(null)
|
||||
const previousSnapRef = useRef<[number, number] | null>(null)
|
||||
const cursorVisibleRef = useRef(false)
|
||||
const [cursorVisible, setCursorVisible] = useState(false)
|
||||
|
||||
// Default-preset column for the placement ghost — matches exactly what the
|
||||
// commit creates (`basicPillar`), so the preview is faithful.
|
||||
@@ -60,6 +65,9 @@ const ColumnTool = () => {
|
||||
useEffect(() => {
|
||||
if (!activeLevelId) return
|
||||
previousSnapRef.current = null
|
||||
cursorVisibleRef.current = false
|
||||
setCursorVisible(false)
|
||||
const lastCursorRef: { current: [number, number, number] | null } = { current: null }
|
||||
|
||||
// Alignment candidates — anchors of every other alignable object, gathered
|
||||
// here and refreshed after each placement so a just-placed column becomes a
|
||||
@@ -68,30 +76,21 @@ const ColumnTool = () => {
|
||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP)
|
||||
|
||||
// Figma-style alignment snap layered on top of grid snap: when the
|
||||
// preview column's footprint edge lines up (on X or Z) with another
|
||||
// object's edge, snap there and publish a guide. Alt bypasses.
|
||||
let ax = sx
|
||||
let az = sz
|
||||
const bypass = event.nativeEvent?.altKey === true
|
||||
if (!bypass && alignmentCandidates.length > 0) {
|
||||
const result = resolveAlignment({
|
||||
moving: movingFootprintAnchors(previewNode, sx, sz, 0),
|
||||
candidates: alignmentCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
if (result.snap) {
|
||||
ax += result.snap.dx
|
||||
az += result.snap.dz
|
||||
}
|
||||
useAlignmentGuides.getState().set(result.guides)
|
||||
} else {
|
||||
useAlignmentGuides.getState().clear()
|
||||
if (!cursorVisibleRef.current) {
|
||||
cursorVisibleRef.current = true
|
||||
setCursorVisible(true)
|
||||
}
|
||||
|
||||
const position: [number, number, number] = [ax, 0, az]
|
||||
const { position, guides } = resolveAlignedFloorPlacement({
|
||||
node: previewNode,
|
||||
rawX: event.localPosition[0],
|
||||
rawZ: event.localPosition[2],
|
||||
gridStep: useEditor.getState().gridSnapStep,
|
||||
candidates: alignmentCandidates,
|
||||
bypassAlignment: event.nativeEvent?.altKey === true,
|
||||
})
|
||||
useAlignmentGuides.getState().set(guides)
|
||||
|
||||
const visualPosition = getFloorStackPreviewPosition({
|
||||
node: previewNode,
|
||||
position,
|
||||
@@ -99,6 +98,7 @@ const ColumnTool = () => {
|
||||
levelId: activeLevelId,
|
||||
})
|
||||
cursorRef.current?.position.set(...visualPosition)
|
||||
lastCursorRef.current = position
|
||||
|
||||
// Publish a transient, positioned preview node for the 2D floor-plan
|
||||
// ghost (the 3D `ColumnPreview` mesh is hidden in 2D). The floor-plan
|
||||
@@ -107,30 +107,18 @@ const ColumnTool = () => {
|
||||
usePlacementPreview.getState().set({ ...previewNode, position })
|
||||
|
||||
const prev = previousSnapRef.current
|
||||
if (!prev || prev[0] !== ax || prev[1] !== az) {
|
||||
if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
previousSnapRef.current = [ax, az]
|
||||
previousSnapRef.current = [position[0], position[2]]
|
||||
}
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP)
|
||||
let ax = sx
|
||||
let az = sz
|
||||
const bypass = event.nativeEvent?.altKey === true
|
||||
if (!bypass && alignmentCandidates.length > 0) {
|
||||
const result = resolveAlignment({
|
||||
moving: movingFootprintAnchors(previewNode, sx, sz, 0),
|
||||
candidates: alignmentCandidates,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
if (result.snap) {
|
||||
ax += result.snap.dx
|
||||
az += result.snap.dz
|
||||
}
|
||||
}
|
||||
const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => {
|
||||
const position =
|
||||
lastCursorRef.current ??
|
||||
getLevelLocalSnappedPosition(activeLevelId, event, useEditor.getState().gridSnapStep)
|
||||
|
||||
const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, [ax, 0, az])
|
||||
const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position)
|
||||
useScene.getState().createNode(column, activeLevelId)
|
||||
useViewer.getState().setSelection({ selectedIds: [column.id] })
|
||||
triggerSFX('sfx:structure-build')
|
||||
@@ -140,14 +128,15 @@ const ColumnTool = () => {
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
|
||||
useAlignmentGuides.getState().clear()
|
||||
usePlacementPreview.getState().clear()
|
||||
stopPlacementCommitPropagation(event)
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
const unsubscribePlacementClicks = subscribeFloorPlacementClicks(commitAtCursor)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
unsubscribePlacementClicks()
|
||||
useAlignmentGuides.getState().clear()
|
||||
usePlacementPreview.getState().clear()
|
||||
}
|
||||
@@ -156,7 +145,7 @@ const ColumnTool = () => {
|
||||
if (!activeLevelId) return null
|
||||
|
||||
return (
|
||||
<group ref={cursorRef}>
|
||||
<group ref={cursorRef} visible={cursorVisible}>
|
||||
<ColumnPreview node={previewNode} />
|
||||
</group>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user