Improve editor manipulation flows
This commit is contained in:
@@ -11,10 +11,11 @@ import {
|
||||
import {
|
||||
applyFloorplanAlignment,
|
||||
getFloorStackPreviewPosition,
|
||||
snapPointToGrid,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
|
||||
/**
|
||||
* 2D floor-plan move handler for shelf — mirrors `itemFloorplanMoveTarget`,
|
||||
@@ -38,12 +39,14 @@ import {
|
||||
* live transform — the 2D SVG moved but the 3D mesh stayed put. Writing the
|
||||
* scene directly removes that second source of truth entirely.
|
||||
*/
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node, nodes }) => {
|
||||
const shelfId = node.id as AnyNodeId
|
||||
const originalPosition: [number, number, number] = [...node.position] as [number, number, number]
|
||||
const originalRotationY = node.rotation[1] ?? 0
|
||||
const resolveCursor = createFloorplanCursorResolver({
|
||||
original: [originalPosition[0], originalPosition[2]],
|
||||
metadata: node.metadata,
|
||||
})
|
||||
let lastPosition: [number, number, number] = originalPosition
|
||||
let lastSnapKey: string | null = null
|
||||
|
||||
@@ -55,9 +58,12 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [shelfId],
|
||||
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 — the shelf footprint
|
||||
// edges snap to neighbours / wall faces and a guide is published. Alt
|
||||
// bypasses (matches placement tools' "No snap").
|
||||
|
||||
@@ -1,91 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
collectAlignmentAnchors,
|
||||
type EventSuffix,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
movingFootprintAnchors,
|
||||
type NodeEvent,
|
||||
resolveAlignment,
|
||||
ShelfNode,
|
||||
sceneRegistry,
|
||||
snapPointToGrid,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { getFloorStackPreviewPosition, triggerSFX } from '@pascal-app/editor'
|
||||
import { getFloorStackPreviewPosition, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { type Group, Vector3 } from 'three'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Group } from 'three'
|
||||
import {
|
||||
type FloorPlacementClickTriggerEvent,
|
||||
getLevelLocalSnappedPosition,
|
||||
resolveAlignedFloorPlacement,
|
||||
stopPlacementCommitPropagation,
|
||||
subscribeFloorPlacementClicks,
|
||||
} from '../shared/floor-placement'
|
||||
import { shelfDefinition } from './definition'
|
||||
import ShelfPreview from './preview'
|
||||
|
||||
const worldVector = new Vector3()
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
/** Figma-style alignment-snap threshold (meters), matching the move tools and
|
||||
* the 2D floor-plan overlay. 8 cm gives a magnetic pull layered on top of the
|
||||
* grid snap without fighting it. */
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
/**
|
||||
* Click-trigger kinds: when the user clicks ANY of these during shelf
|
||||
* placement, we commit at the latest cursor position. R3F's pointer
|
||||
* raycaster dispatches to the closest intersected mesh, so a click on
|
||||
* a wall / slab / item / etc. would otherwise never reach `grid:click`
|
||||
* — the placement would silently drop. Listening for each kind's click
|
||||
* (and committing at the snapshot of the last `grid:move` cursor)
|
||||
* mirrors the fix in `MoveRegistryNodeTool`.
|
||||
*/
|
||||
const CLICK_TRIGGER_KINDS = [
|
||||
'shelf',
|
||||
'item',
|
||||
'slab',
|
||||
'ceiling',
|
||||
'wall',
|
||||
'fence',
|
||||
'column',
|
||||
'roof',
|
||||
'roof-segment',
|
||||
'stair',
|
||||
'stair-segment',
|
||||
] as const
|
||||
|
||||
type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
|
||||
|
||||
/**
|
||||
* Convert the latest cursor world hit into level-local coords for the
|
||||
* commit `position`. The cursor's local position from `event.localPosition`
|
||||
* (building-local) needs to come back through the level's world transform
|
||||
* so the shelf is stored in its parent's frame.
|
||||
*/
|
||||
function getLevelLocalPosition(
|
||||
levelId: string,
|
||||
event: GridEvent | NodeEvent<AnyNode>,
|
||||
): [number, number, number] {
|
||||
const levelObject = sceneRegistry.nodes.get(levelId)
|
||||
if (!levelObject) {
|
||||
const local = (event as GridEvent).localPosition
|
||||
if (local) {
|
||||
const [sx, sz] = snapPointToGrid([local[0], local[2]], GRID_STEP)
|
||||
return [sx, 0, sz]
|
||||
}
|
||||
const [sx, sz] = snapPointToGrid([event.position[0], event.position[2]], GRID_STEP)
|
||||
return [sx, 0, sz]
|
||||
}
|
||||
worldVector.set(event.position[0], event.position[1], event.position[2])
|
||||
levelObject.updateWorldMatrix(true, false)
|
||||
levelObject.worldToLocal(worldVector)
|
||||
const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], GRID_STEP)
|
||||
return [sx, 0, sz]
|
||||
}
|
||||
|
||||
const ShelfTool = () => {
|
||||
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-shaped shelf for the placement preview. Pulls from
|
||||
// `shelfDefinition.defaults()` so the preview matches what the commit
|
||||
@@ -108,6 +50,8 @@ const ShelfTool = () => {
|
||||
useEffect(() => {
|
||||
if (!activeLevelId) return
|
||||
previousSnapRef.current = null
|
||||
cursorVisibleRef.current = false
|
||||
setCursorVisible(false)
|
||||
/**
|
||||
* Snapped cursor position from the latest `grid:move`. Used as the
|
||||
* commit position for ANY click variant (grid or node), so clicks
|
||||
@@ -124,33 +68,21 @@ const ShelfTool = () => {
|
||||
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 shelf's footprint edge lines up (on X or Z) with another
|
||||
// object's edge, snap there and publish a guide. The probe uses the
|
||||
// shelf's footprint corners at the proposed grid position so it aligns
|
||||
// by its edges, not its centre — matching `MoveRegistryNodeTool`. 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,
|
||||
@@ -161,18 +93,20 @@ const ShelfTool = () => {
|
||||
lastCursorRef.current = 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 commitAtCursor = (event: ClickTriggerEvent) => {
|
||||
const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => {
|
||||
// Prefer the latest `grid:move` cursor snapshot; fall back to
|
||||
// projecting the click event into level-local coords if no
|
||||
// grid:move has fired yet (e.g. cursor entered via a node hit
|
||||
// first). Both paths apply the same grid snap.
|
||||
const position = lastCursorRef.current ?? getLevelLocalPosition(activeLevelId, event)
|
||||
const position =
|
||||
lastCursorRef.current ??
|
||||
getLevelLocalSnappedPosition(activeLevelId, event, useEditor.getState().gridSnapStep)
|
||||
const shelf = ShelfNode.parse({
|
||||
...shelfDefinition.defaults(),
|
||||
name: 'Shelf',
|
||||
@@ -187,33 +121,15 @@ const ShelfTool = () => {
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
|
||||
useAlignmentGuides.getState().clear()
|
||||
|
||||
const native = (event as { nativeEvent?: unknown }).nativeEvent
|
||||
if (
|
||||
native &&
|
||||
typeof (native as { stopPropagation?: () => void }).stopPropagation === 'function'
|
||||
) {
|
||||
;(native as { stopPropagation: () => void }).stopPropagation()
|
||||
}
|
||||
const direct = (event as { stopPropagation?: () => void }).stopPropagation
|
||||
if (typeof direct === 'function') direct.call(event)
|
||||
stopPlacementCommitPropagation(event)
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', commitAtCursor)
|
||||
type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
|
||||
type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
|
||||
for (const kind of CLICK_TRIGGER_KINDS) {
|
||||
const key = `${kind}:click` as ClickKey
|
||||
emitter.on(key, commitAtCursor as never)
|
||||
}
|
||||
const unsubscribePlacementClicks = subscribeFloorPlacementClicks(commitAtCursor)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', commitAtCursor)
|
||||
for (const kind of CLICK_TRIGGER_KINDS) {
|
||||
const key = `${kind}:click` as ClickKey
|
||||
emitter.off(key, commitAtCursor as never)
|
||||
}
|
||||
unsubscribePlacementClicks()
|
||||
// Drop any alignment guide left over when the tool deactivates (kind
|
||||
// switch, Esc, unmount) so it doesn't linger over the canvas.
|
||||
useAlignmentGuides.getState().clear()
|
||||
@@ -223,7 +139,7 @@ const ShelfTool = () => {
|
||||
if (!activeLevelId) return null
|
||||
|
||||
return (
|
||||
<group ref={cursorRef}>
|
||||
<group ref={cursorRef} visible={cursorVisible}>
|
||||
<ShelfPreview node={previewNode} />
|
||||
</group>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user