Improve editor manipulation flows

This commit is contained in:
Aymeric Rabot
2026-06-08 01:07:53 -04:00
parent ab271df9b6
commit 8dc602caa9
57 changed files with 3442 additions and 735 deletions
+12 -6
View File
@@ -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,
+60 -21
View File
@@ -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 (
<>
+44 -55
View File
@@ -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>
)
+19 -2
View File
@@ -4,9 +4,15 @@ import {
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
useScene,
type WallNode,
} from '@pascal-app/core'
import { snapToHalf } from '@pascal-app/editor'
import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import {
findClosestWallInPlan,
projectWallLocalPointToPlan,
snapLocalXToNeighbors,
} from '../shared/wall-attach-target'
import { clampToWall, hasWallChildOverlap } from './door-math'
/**
@@ -36,6 +42,16 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
const wall = useScene.getState().nodes[node.parentId as AnyNodeId]
return wall ? (wall.parentId as AnyNodeId | null) : null
})()
const originalWall = node.parentId
? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined)
: undefined
const resolveCursor = createFloorplanCursorResolver({
original:
originalWall?.type === 'wall'
? projectWallLocalPointToPlan(originalWall, node.position[0])
: [node.position[0], 0],
metadata: node.metadata,
})
// Track the last successful placement so `commit()` can write it
// atomically — see the comment on `commit` below for why we don't
@@ -52,7 +68,8 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) {
const nodes = useScene.getState().nodes
const hit = findClosestWallInPlan(planPoint, nodes, startLevelId)
const resolvedPlanPoint = resolveCursor(planPoint)
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
if (!hit) return // pointer off any wall — keep door at last valid position
// Figma-style along-wall alignment first (edge-to-edge with other
+36 -12
View File
@@ -17,6 +17,7 @@ import { ElevatorNode } from './schema'
const SIDE_HANDLE_OFFSET = 0.22
const HEIGHT_HANDLE_OFFSET = 0.3
const MOVE_FRONT_OFFSET = 0.35
const MIN_ELEVATOR_DIM = 0.6
const MIN_CAB_HEIGHT = 1.4
const ROTATE_CORNER_OFFSET = 0.4
@@ -81,6 +82,16 @@ function elevatorCabHeightHandle(): HandleDescriptor<ElevatorNodeType> {
}
}
function elevatorOuterHalfExtents(n: ElevatorNodeType): { halfX: number; halfZ: number } {
const cabWidth = getElevatorCabWidth(n)
const cabDepth = getElevatorCabDepth(n)
const wallThickness = getElevatorShaftWallThickness(n)
return {
halfX: getElevatorShaftWidth(n, cabWidth) / 2 + wallThickness,
halfZ: getElevatorShaftDepth(n, cabDepth) / 2 + wallThickness,
}
}
// Rotation handle — sits at the front-right corner of the shaft
// footprint. `arc-resize` does the angular drag math (raycasts a
// horizontal plane at the arrow's Y, measures cursor angle around the
@@ -103,11 +114,7 @@ function elevatorRotateHandle(): HandleDescriptor<ElevatorNodeType> {
// shaft rather than diagonally at the corner — matches the column's
// one-direction rotate placement.
position: (n) => {
const cabWidth = getElevatorCabWidth(n)
const cabDepth = getElevatorCabDepth(n)
const wallThickness = getElevatorShaftWallThickness(n)
const halfX = getElevatorShaftWidth(n, cabWidth) / 2 + wallThickness
const halfZ = getElevatorShaftDepth(n, cabDepth) / 2 + wallThickness
const { halfX, halfZ } = elevatorOuterHalfExtents(n)
const yMid = Math.max(n.cabHeight, MIN_CAB_HEIGHT) / 2
return [halfX, yMid, halfZ + ROTATE_CORNER_OFFSET]
},
@@ -120,11 +127,7 @@ function elevatorRotateHandle(): HandleDescriptor<ElevatorNodeType> {
// Bounding circle through the shaft corners — drawn slightly larger
// so it sits outside the visible shell.
radius: (n) => {
const cabWidth = getElevatorCabWidth(n)
const cabDepth = getElevatorCabDepth(n)
const wallThickness = getElevatorShaftWallThickness(n)
const halfX = getElevatorShaftWidth(n, cabWidth) / 2 + wallThickness
const halfZ = getElevatorShaftDepth(n, cabDepth) / 2 + wallThickness
const { halfX, halfZ } = elevatorOuterHalfExtents(n)
return Math.hypot(halfX, halfZ) + ROTATE_RING_OFFSET
},
y: (n) => Math.max(n.cabHeight, MIN_CAB_HEIGHT) / 2,
@@ -132,11 +135,32 @@ function elevatorRotateHandle(): HandleDescriptor<ElevatorNodeType> {
}
}
function elevatorMoveHandle(): HandleDescriptor<ElevatorNodeType> {
return {
kind: 'translate',
placement: {
position: (n) => {
const { halfZ } = elevatorOuterHalfExtents(n)
return [0, 0.02, halfZ + MOVE_FRONT_OFFSET]
},
},
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
snapExtents: (n) => {
const { halfX, halfZ } = elevatorOuterHalfExtents(n)
const dimX = Math.max(halfX * 2, MIN_ELEVATOR_DIM)
const dimZ = Math.max(halfZ * 2, MIN_ELEVATOR_DIM)
const swap = Math.abs(Math.sin(n.rotation ?? 0)) > 0.9
return [swap ? dimZ : dimX, swap ? dimX : dimZ]
},
}
}
const elevatorHandles: HandleDescriptor<ElevatorNodeType>[] = [
elevatorAxisHandle('x'),
elevatorAxisHandle('z'),
elevatorCabHeightHandle(),
elevatorRotateHandle(),
elevatorMoveHandle(),
]
/**
@@ -174,10 +198,10 @@ export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
// bridge relocates this same footprint to the drag point.
alignmentFootprint: (node) => {
const e = node as ElevatorNodeType
const wall = getElevatorShaftWallThickness(e)
const { halfX, halfZ } = elevatorOuterHalfExtents(e)
return {
shape: 'box',
dimensions: [getElevatorShaftWidth(e) + wall * 2, 1, getElevatorShaftDepth(e) + wall * 2],
dimensions: [halfX * 2, 1, halfZ * 2],
rotation: [0, e.rotation ?? 0, 0],
}
},
+106 -11
View File
@@ -10,7 +10,8 @@ import {
movingFootprintAnchors,
useScene,
} from '@pascal-app/core'
import { applyFloorplanAlignment, snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
import { applyFloorplanAlignment, useEditor, type WallPlanPoint } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target'
/**
@@ -34,7 +35,95 @@ import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-att
* the item's current attach family.
*/
const GRID_STEP = 0.5
type ItemPlanTransform = {
point: [number, number]
rotation: number
}
function rotateVec(x: number, z: number, rotationY: number): [number, number] {
const c = Math.cos(rotationY)
const s = Math.sin(rotationY)
return [x * c + z * s, -x * s + z * c]
}
function resolveItemPlanTransform(
item: ItemNode,
nodes: Record<AnyNodeId, AnyNode>,
cache = new Map<AnyNodeId, ItemPlanTransform>(),
): ItemPlanTransform {
const cached = cache.get(item.id as AnyNodeId)
if (cached) return cached
const localRotation = item.rotation[1] ?? 0
let result: ItemPlanTransform = {
point: [item.position[0], item.position[2]],
rotation: localRotation,
}
const parent = item.parentId ? nodes[item.parentId as AnyNodeId] : null
if (parent?.type === 'wall') {
const wallRotation = -Math.atan2(
parent.end[1] - parent.start[1],
parent.end[0] - parent.start[0],
)
const wallLocalZ =
item.asset.attachTo === 'wall-side'
? ((parent.thickness ?? 0.1) / 2) * (item.side === 'back' ? -1 : 1)
: item.position[2]
const [offsetX, offsetZ] = rotateVec(item.position[0], wallLocalZ, wallRotation)
result = {
point: [parent.start[0] + offsetX, parent.start[1] + offsetZ],
rotation: wallRotation + localRotation,
}
} else if (parent?.type === 'shelf') {
const shelf = parent as AnyNode & {
position: [number, number, number]
rotation: [number, number, number]
}
const [offsetX, offsetZ] = rotateVec(item.position[0], item.position[2], shelf.rotation[1] ?? 0)
result = {
point: [shelf.position[0] + offsetX, shelf.position[2] + offsetZ],
rotation: (shelf.rotation[1] ?? 0) + localRotation,
}
} else if (parent?.type === 'item') {
const parentTransform = resolveItemPlanTransform(parent as ItemNode, nodes, cache)
const [offsetX, offsetZ] = rotateVec(
item.position[0],
item.position[2],
parentTransform.rotation,
)
result = {
point: [parentTransform.point[0] + offsetX, parentTransform.point[1] + offsetZ],
rotation: parentTransform.rotation + localRotation,
}
}
cache.set(item.id as AnyNodeId, result)
return result
}
function resolveItemPlanPoint(
item: ItemNode,
nodes: Record<AnyNodeId, AnyNode>,
cache = new Map<AnyNodeId, ItemPlanTransform>(),
): [number, number] {
return resolveItemPlanTransform(item, nodes, cache).point
}
function createPlanarMovePointResolver(originalPlanPoint: [number, number], node: ItemNode) {
const resolveCursor = createFloorplanCursorResolver({
original: originalPlanPoint,
metadata: node.metadata,
})
return (planPoint: readonly [number, number], shiftKey: boolean): WallPlanPoint => {
const snap = (value: number) => {
if (shiftKey) return value
const step = useEditor.getState().gridSnapStep
return Math.round(value / step) * step
}
return resolveCursor(planPoint, { snap }) as WallPlanPoint
}
}
export const itemFloorplanMoveTarget: FloorplanMoveTarget<ItemNode> = ({ node, nodes }) => {
const attachTo = node.asset.attachTo
@@ -77,12 +166,17 @@ function buildWallItemSession(
// local-Y carries over from the source item's position (2D can't
// express vertical movement).
const startLocalY = node.position[1]
const resolveCursor = createFloorplanCursorResolver({
original: resolveItemPlanPoint(node, useScene.getState().nodes),
metadata: node.metadata,
})
return {
affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) {
const nodes = useScene.getState().nodes
const hit = findClosestWallInPlan(planPoint, nodes, startLevelId)
const resolvedPlanPoint = resolveCursor(planPoint)
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
if (!hit) return
const [width] = getScaledDimensions(node)
@@ -99,9 +193,9 @@ function buildWallItemSession(
selfId: node.id as AnyNodeId,
nodes,
})
const step = useEditor.getState().gridSnapStep
const snappedLocalX =
neighborX ??
(modifiers.shiftKey ? hit.localX : Math.round(hit.localX / GRID_STEP) * GRID_STEP)
neighborX ?? (modifiers.shiftKey ? hit.localX : Math.round(hit.localX / step) * step)
const halfW = width / 2
const clampedX = Math.max(halfW, Math.min(hit.wallLength - halfW, snappedLocalX))
@@ -143,14 +237,13 @@ function buildFloorItemSession(
nodes: Record<AnyNodeId, AnyNode>,
): FloorplanMoveTargetSession {
const rotationY = node.rotation[1] ?? 0
const resolvePlanPoint = createPlanarMovePointResolver(resolveItemPlanPoint(node, nodes), node)
// Alignment candidates gathered once — scene is stable during the drag.
const candidates = collectAlignmentAnchors(nodes, node.id)
return {
affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) {
const gridSnapped: WallPlanPoint = modifiers.shiftKey
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
const gridSnapped = resolvePlanPoint(planPoint, modifiers.shiftKey)
// Figma-style alignment layered on the grid snap (Alt bypasses).
const { point: snapped } = applyFloorplanAlignment(
gridSnapped,
@@ -200,13 +293,15 @@ function buildSurfaceItemSession(
startLevelId: AnyNodeId | null,
targetKind: 'ceiling',
): FloorplanMoveTargetSession {
const resolvePlanPoint = createPlanarMovePointResolver(
resolveItemPlanPoint(node, useScene.getState().nodes),
node,
)
return {
affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) {
const nodes = useScene.getState().nodes
const snapped: WallPlanPoint = modifiers.shiftKey
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
const snapped = resolvePlanPoint(planPoint, modifiers.shiftKey)
const surface = findContainingSurface(snapped, nodes, startLevelId, targetKind)
@@ -7,6 +7,8 @@ import {
snapScalar,
useScene,
} from '@pascal-app/core'
import { getSegmentGridStep } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
const MIN_ROOF_DIM = 1
@@ -148,11 +150,15 @@ export const roofSegmentRotateAffordance: FloorplanAffordance<RoofSegmentNode> =
export const roofSegmentMoveTarget: FloorplanMoveTarget<RoofSegmentNode> = ({ node, nodes }) => {
const segmentId = node.id as AnyNodeId
const initialY = node.position[1]
const { roofRot, cosRoof, sinRoof } = resolveSegmentFrame(node, nodes)
const { cx, cz, roofRot, cosRoof, sinRoof } = resolveSegmentFrame(node, nodes)
const roofId = (node as unknown as { parentId?: AnyNodeId | null }).parentId
const roof = roofId ? (nodes[roofId] as RoofNode | undefined) : undefined
const roofPosX = roof?.position[0] ?? 0
const roofPosZ = roof?.position[2] ?? 0
const resolveCursor = createFloorplanCursorResolver({
original: [cx, cz],
metadata: node.metadata,
})
// Inverse of the forward transform `[cosRoof, -sinRoof; sinRoof, cosRoof]`
// is `[cosRoof, sinRoof; -sinRoof, cosRoof]`. Used to project world cursor
// back into roof-local coords.
@@ -162,17 +168,13 @@ export const roofSegmentMoveTarget: FloorplanMoveTarget<RoofSegmentNode> = ({ no
return {
affectedIds: [segmentId],
apply({ planPoint, modifiers }) {
const dx = planPoint[0] - roofPosX
const dz = planPoint[1] - roofPosZ
const step = getSegmentGridStep()
const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step))
const worldPoint = resolveCursor(planPoint, { snap })
const dx = worldPoint[0] - roofPosX
const dz = worldPoint[1] - roofPosZ
let localX = dx * cosRoof + dz * sinRoof
let localZ = -dx * sinRoof + dz * cosRoof
// 0.5m grid snap (alt held disables). Mirrors the generic Path 2
// fallback's `snapPointToGrid` step so floor-plan moves feel
// consistent across kinds.
if (!modifiers.altKey) {
localX = Math.round(localX * 2) / 2
localZ = Math.round(localZ * 2) / 2
}
lastLocal = [localX, initialY, localZ]
useScene.getState().updateNode(segmentId, { position: lastLocal })
},
@@ -0,0 +1,37 @@
import { describe, expect, test } from 'bun:test'
import { type GridEvent, type NodeEvent, ShelfNode } from '@pascal-app/core'
import { Object3D } from 'three'
import { getLevelLocalSnappedPosition, resolveAlignedFloorPlacement } from './floor-placement'
const nativeEvent = {} as GridEvent['nativeEvent']
describe('floor placement helpers', () => {
test('resolveAlignedFloorPlacement snaps to the provided grid step', () => {
const node = ShelfNode.parse({ position: [0, 0, 0] })
const { guides, position } = resolveAlignedFloorPlacement({
node,
rawX: 0.13,
rawZ: 0.37,
gridStep: 0.25,
candidates: [],
})
expect(position).toEqual([0.25, 0, 0.25])
expect(guides).toEqual([])
})
test('getLevelLocalSnappedPosition falls back to node world position for node events', () => {
const node = ShelfNode.parse({ position: [0, 0, 0] })
const event: NodeEvent = {
node,
position: [0.13, 0, 0.37],
localPosition: [42, 0, 42],
object: new Object3D(),
stopPropagation: () => {},
nativeEvent,
}
expect(getLevelLocalSnappedPosition('missing-level', event, 0.25)).toEqual([0.25, 0, 0.25])
})
})
@@ -0,0 +1,125 @@
import {
type AnyNode,
type EventSuffix,
emitter,
type GridEvent,
movingFootprintAnchors,
type NodeEvent,
resolveAlignment,
sceneRegistry,
snapPointToGrid,
} from '@pascal-app/core'
import { Vector3 } from 'three'
export const FLOOR_PLACEMENT_ALIGNMENT_THRESHOLD_M = 0.08
export const FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS = [
'shelf',
'item',
'slab',
'ceiling',
'wall',
'fence',
'column',
'roof',
'roof-segment',
'stair',
'stair-segment',
] as const
export type FloorPlacementClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
type FloorPlacementAlignmentArgs = {
node: AnyNode
rawX: number
rawZ: number
gridStep: number
candidates: Parameters<typeof resolveAlignment>[0]['candidates']
bypassAlignment?: boolean
rotationY?: number
}
const worldVector = new Vector3()
export function getLevelLocalSnappedPosition(
levelId: string,
event: FloorPlacementClickTriggerEvent,
gridStep: number,
): [number, number, number] {
const levelObject = sceneRegistry.nodes.get(levelId)
if (!levelObject) {
const rawPoint = 'node' in event ? event.position : event.localPosition
const [sx, sz] = snapPointToGrid([rawPoint[0], rawPoint[2]], gridStep)
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], gridStep)
return [sx, 0, sz]
}
export function resolveAlignedFloorPlacement({
node,
rawX,
rawZ,
gridStep,
candidates,
bypassAlignment = false,
rotationY = 0,
}: FloorPlacementAlignmentArgs) {
const [sx, sz] = snapPointToGrid([rawX, rawZ], gridStep)
let ax = sx
let az = sz
const result =
!bypassAlignment && candidates.length > 0
? resolveAlignment({
moving: movingFootprintAnchors(node, sx, sz, rotationY),
candidates,
threshold: FLOOR_PLACEMENT_ALIGNMENT_THRESHOLD_M,
})
: null
if (result?.snap) {
ax += result.snap.dx
az += result.snap.dz
}
return {
position: [ax, 0, az] as [number, number, number],
guides: result?.guides ?? [],
}
}
export function stopPlacementCommitPropagation(event: FloorPlacementClickTriggerEvent) {
const native = (event as { nativeEvent?: unknown }).nativeEvent
const nativeStopPropagation = (native as { stopPropagation?: () => void } | undefined)
?.stopPropagation
if (typeof nativeStopPropagation === 'function') {
nativeStopPropagation.call(native)
}
const direct = (event as { stopPropagation?: () => void }).stopPropagation
if (typeof direct === 'function') direct.call(event)
}
export function subscribeFloorPlacementClicks(
onClick: (event: FloorPlacementClickTriggerEvent) => void,
) {
emitter.on('grid:click', onClick)
type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
type ClickKey = SuffixedKey<(typeof FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS)[number]>
for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) {
const key = `${kind}:click` as ClickKey
emitter.on(key, onClick as never)
}
return () => {
emitter.off('grid:click', onClick)
for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) {
const key = `${kind}:click` as ClickKey
emitter.off(key, onClick as never)
}
}
}
@@ -0,0 +1,29 @@
import { describe, expect, test } from 'bun:test'
import { createFloorplanCursorResolver } from './floorplan-cursor'
describe('createFloorplanCursorResolver', () => {
test('keeps existing nodes at their original position on the first cursor sample', () => {
const resolveCursor = createFloorplanCursorResolver({ original: [4, 6] })
expect(resolveCursor([10, 12])).toEqual([4, 6])
expect(resolveCursor([11, 14])).toEqual([5, 8])
})
test('places fresh nodes absolutely under the cursor', () => {
const resolveCursor = createFloorplanCursorResolver({
original: [0, 0],
metadata: { isNew: true },
})
expect(resolveCursor([10, 12])).toEqual([10, 12])
expect(resolveCursor([11, 14])).toEqual([11, 14])
})
test('snaps relative movement without snapping the original position', () => {
const resolveCursor = createFloorplanCursorResolver({ original: [4.1, 6.1] })
const snap = (value: number) => Math.round(value / 0.5) * 0.5
expect(resolveCursor([10.1, 12.1], { snap })).toEqual([4.1, 6.1])
expect(resolveCursor([10.37, 12.88], { snap })).toEqual([4.6, 7.1])
})
})
@@ -0,0 +1,35 @@
import {
isFreshPlacementMetadata,
type PlanarCursorPlacementMode,
type PlanarPoint,
resolvePlanarCursorPosition,
} from '@pascal-app/editor'
type FloorplanCursorResolverOptions = {
snap?: (value: number) => number
}
export function createFloorplanCursorResolver(args: {
original: readonly [number, number]
metadata?: unknown
mode?: PlanarCursorPlacementMode
}) {
const original: PlanarPoint = [args.original[0], args.original[1]]
const mode = args.mode ?? (isFreshPlacementMetadata(args.metadata) ? 'absolute' : 'relative')
let anchor: PlanarPoint | null = null
return (
planPoint: readonly [number, number],
options: FloorplanCursorResolverOptions = {},
): PlanarPoint => {
const resolved = resolvePlanarCursorPosition({
cursor: [planPoint[0], planPoint[1]],
original,
anchor,
mode,
...(options.snap ? { snap: options.snap } : {}),
})
anchor = resolved.anchor
return resolved.point
}
}
+59 -45
View File
@@ -5,6 +5,7 @@ import {
type FenceNode,
type GridEvent,
type LevelNode,
movingAlignmentAnchors,
nodeRegistry,
type RoofNode,
type RoofSegmentNode,
@@ -19,11 +20,14 @@ import {
} from '@pascal-app/core'
import {
CursorSphere,
clearRoofDuplicateMetadata,
commitFreshPlacementSubtree,
getFloorStackPreviewPosition,
resolvePlanarCursorPosition,
snapFenceDraftPoint,
stripPlacementMetadataFlags,
triggerSFX,
useEditor,
useFreshPlacementVisibility,
type WallPlanPoint,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
@@ -36,6 +40,15 @@ const ALIGNMENT_THRESHOLD_M = 0.08
export const MoveRoofTool: React.FC<{
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
}> = ({ node: movingNode }) => {
const {
isFreshPlacement,
previewVisible: cursorVisible,
revealFreshPlacement,
useAbsoluteCursorPlacement,
} = useFreshPlacementVisibility({
node: movingNode,
enabled: movingNode.type === 'roof' || movingNode.type === 'stair',
})
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
@@ -82,25 +95,8 @@ export const MoveRoofTool: React.FC<{
dragAnchorRef.current = null
previousGridPosRef.current = null
const meta =
typeof movingNode.metadata === 'object' && movingNode.metadata !== null
? (movingNode.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
const committedMeta: RoofNode['metadata'] = (() => {
if (
typeof movingNode.metadata !== 'object' ||
movingNode.metadata === null ||
Array.isArray(movingNode.metadata)
) {
return movingNode.metadata
}
const nextMeta = { ...movingNode.metadata } as Record<string, unknown>
delete nextMeta.isNew
delete nextMeta.isTransient
return nextMeta as RoofNode['metadata']
})()
const isNew = isFreshPlacement
const committedMeta = stripPlacementMetadataFlags(movingNode.metadata) as RoofNode['metadata']
const original = {
position: [...movingNode.position] as [number, number, number],
@@ -115,6 +111,7 @@ export const MoveRoofTool: React.FC<{
// expensive merged-mesh CSG rebuilds on every frame.
let wasCommitted = false
let wasCancelled = false
let hasMoved = false
// Track pending rotation — no store updates during drag
let pendingRotation: number = movingNode.rotation as number
@@ -190,20 +187,28 @@ export const MoveRoofTool: React.FC<{
// Alignment for top-level stair / roof only. Segments live in parent-local
// space (a different frame from the building-local candidate pool / guide
// layer), so we leave them on the plain grid+corner snap. The moving node
// is aligned by its ORIGIN point (how this tool positions it), snapped to
// any other alignable object's anchors.
// layer), so we leave them on the plain grid+corner snap. Stairs align by
// their footprint edges; roofs keep the origin-point behavior.
const alignTopLevel = movingNode.type === 'stair' || movingNode.type === 'roof'
const alignmentCandidates = alignTopLevel
? collectAlignmentAnchors(useScene.getState().nodes, movingNode.id)
? collectAlignmentAnchors(
useScene.getState().nodes,
movingNode.id,
movingNode.type === 'stair' ? levelId : undefined,
)
: []
const alignLocalPoint = (lx: number, lz: number, bypass: boolean): [number, number] => {
if (!alignTopLevel || bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return [lx, lz]
}
const moving =
movingNode.type === 'stair'
? movingAlignmentAnchors(movingNode, useScene.getState().nodes, lx, lz, pendingRotation)
: []
const ar = resolveAlignment({
moving: [{ nodeId: movingNode.id, kind: 'corner', x: lx, z: lz }],
moving:
moving.length > 0 ? moving : [{ nodeId: movingNode.id, kind: 'corner', x: lx, z: lz }],
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
@@ -277,6 +282,9 @@ export const MoveRoofTool: React.FC<{
}
const onGridMove = (event: GridEvent) => {
hasMoved = true
revealFreshPlacement()
const y = event.position[1]
const snappedLocal = snapFenceDraftPoint({
@@ -292,11 +300,14 @@ export const MoveRoofTool: React.FC<{
snappedLocal[0],
snappedLocal[1],
)
const anchor = dragAnchorRef.current ?? [rawLocalX, rawLocalZ]
dragAnchorRef.current = anchor
let localX = movingNode.position[0] + (rawLocalX - anchor[0])
let localZ = movingNode.position[2] + (rawLocalZ - anchor[1])
const resolved = resolvePlanarCursorPosition({
cursor: [rawLocalX, rawLocalZ],
original: [movingNode.position[0], movingNode.position[2]],
anchor: dragAnchorRef.current,
mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
})
dragAnchorRef.current = resolved.anchor
let [localX, localZ] = resolved.point
if (alignTopLevel) {
const aligned = alignLocalPoint(localX, localZ, event.nativeEvent?.altKey === true)
@@ -340,34 +351,37 @@ export const MoveRoofTool: React.FC<{
}
const onGridClick = (event: GridEvent) => {
if (!hasMoved) return
const [localX, , localZ] = lastLocalPosition
useAlignmentGuides.getState().clear()
wasCommitted = true
// The store still holds the original values (we didn't update during drag).
// Resume temporal and apply the final state as a single undoable step.
useScene.temporal.getState().resume()
if (isNew && movingNode.type === 'roof') {
clearRoofDuplicateMetadata(movingNode.id as AnyNodeId, {
position: [localX, movingNode.position[1], localZ],
rotation: pendingRotation,
metadata: committedMeta,
})
let committedId = movingNode.id as AnyNodeId
if (isNew) {
committedId =
commitFreshPlacementSubtree(movingNode.id as AnyNodeId, {
position: [localX, movingNode.position[1], localZ],
rotation: pendingRotation,
metadata: committedMeta,
visible: true,
}) ?? committedId
} else {
// The store still holds the original values (we didn't update during drag).
// Resume temporal and apply the final state as a single undoable step.
useScene.temporal.getState().resume()
useScene.getState().updateNode(movingNode.id, {
position: [localX, movingNode.position[1], localZ],
rotation: pendingRotation,
metadata: committedMeta,
})
useScene.temporal.getState().pause()
}
useScene.temporal.getState().pause()
triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [movingNode.id] })
useViewer.getState().setSelection({ selectedIds: [committedId] })
useLiveTransforms.getState().clear(movingNode.id)
useEditor.getState().setMovingNodeOrigin('3d')
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
@@ -463,10 +477,10 @@ export const MoveRoofTool: React.FC<{
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
}
}, [movingNode, exitMoveMode])
}, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement])
return (
<group>
<group visible={cursorVisible}>
<CursorSphere position={cursorWorldPos} showTooltip={false} />
</group>
)
@@ -10,17 +10,16 @@ import {
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
import { getSegmentGridStep, type WallPlanPoint } from '@pascal-app/editor'
import type * as THREE from 'three'
import { createFloorplanCursorResolver } from './floorplan-cursor'
/**
* Shared 2D floor-plan move for polygon-based kinds (slab / ceiling / zone).
*
* **Pivot semantics.** The move uses the polygon's **centroid** as the pivot:
* the centroid snaps to the (grid-snapped, then Figma-aligned) cursor — the
* same way a regular item's origin snaps to the cursor in both 3D and 2D.
* This replaces the old grab-relative delta ("drag from wherever you first
* touched"), so polygon kinds move consistently with every other item.
* Existing polygon kinds preserve the cursor grab offset; fresh catalog
* placement uses the polygon centroid as the cursor-following pivot. This
* matches the generic 3D move tool while keeping polygon geometry in vertices.
*
* **Why a delta in `useLiveTransforms`** (see `wiki/architecture/tools.md`):
* polygon kinds carry their position in their vertices, not a `position`
@@ -35,8 +34,6 @@ import type * as THREE from 'three'
* ceiling: `height 0.01`) so the 3D mesh doesn't teleport vertically in a
* split view during the drag.
*/
const GRID_STEP = 0.5
/** Figma-style alignment threshold (meters) — parity with the 3D move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
@@ -66,6 +63,7 @@ export function createPolygonCentroidMoveTarget(args: {
type: string
polygon: Array<[number, number]>
holes?: Array<Array<[number, number]>>
metadata?: unknown
}
nodes: Record<AnyNodeId, AnyNode>
/** 3D mesh Y the kind's system parks the group at on rebuild. */
@@ -80,6 +78,10 @@ export function createPolygonCentroidMoveTarget(args: {
hole.map(([x, z]) => [x, z] as [number, number]),
)
const originalCenter = polygonCentroid(originalPolygon)
const resolveCursor = createFloorplanCursorResolver({
original: originalCenter,
metadata: node.metadata,
})
// Alignment candidates gathered once — the scene is stable during the drag.
const candidates = collectAlignmentAnchors(nodes, id)
let lastDelta: [number, number] = [0, 0]
@@ -90,9 +92,9 @@ export function createPolygonCentroidMoveTarget(args: {
// Centroid → snapped cursor. Grid-snap the target centroid (Shift
// drops the grid snap), then layer Figma alignment on the translated
// polygon's vertices and fold its snap into the delta. Alt bypasses.
const target: WallPlanPoint = modifiers.shiftKey
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
const step = getSegmentGridStep()
const snap = (value: number) => (modifiers.shiftKey ? value : Math.round(value / step) * step)
const target = resolveCursor(planPoint, { snap }) as WallPlanPoint
let dx = target[0] - originalCenter[0]
let dz = target[1] - originalCenter[1]
@@ -49,6 +49,17 @@ export type WallHit = {
itemRotation: number
}
export function projectWallLocalPointToPlan(
wall: WallNode,
localX: number,
localZ = 0,
): [number, number] {
const angle = -Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
const c = Math.cos(angle)
const s = Math.sin(angle)
return [wall.start[0] + localX * c + localZ * s, wall.start[1] - localX * s + localZ * c]
}
/**
* Walk every wall under `parentLevelId` and return the closest one to
* `planPoint`, or `null` if no wall is within `WALL_SNAP_DISTANCE_M`.
+12 -6
View File
@@ -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").
+37 -121
View File
@@ -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>
)
+2 -2
View File
@@ -433,8 +433,8 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
// A stair has no centred box footprint: straight = a cumulative
// `stair-segment` chain, curved / spiral = an annular sector. Hand the
// alignment bridge the resolved plan `aabb` directly (not a `box`) — the
// stair moves by its origin via `affordanceTools.move`, so it only ever
// contributes static candidate anchors, never the relocatable box path.
// moving-anchor helper can relocate the same shape when a stair is being
// placed or dragged.
alignmentFootprint: (node, nodes) => {
const aabb = stairFootprintAABB(node as StairNodeType, nodes)
return aabb ? { shape: 'aabb', ...aabb } : null
+18 -13
View File
@@ -3,24 +3,22 @@ import {
collectAlignmentAnchors,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
movingAlignmentAnchors,
type StairNode,
snapScalar,
useScene,
} from '@pascal-app/core'
import { applyFloorplanAlignment, getSegmentGridStep } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
/**
* 2D floor-plan move handler for stair.
*
* **Pivot semantics.** The stair's ORIGIN (its `position`) follows the
* snapped cursor — the same pivot the 3D move tool (`shared/move-roof-tool`)
* uses: it positions the stair by its origin at the grid-snapped, aligned
* cursor, NOT by the grab offset under the mouse. This replaces the old
* grab-relative delta so dragging in 2D tracks the same point as 3D.
* Existing stairs preserve the cursor grab offset, matching the 3D move
* tools; fresh catalog placement follows the cursor absolutely.
*
* Figma alignment is layered on the origin point (single anchor), matching
* `move-roof-tool`'s "align by origin" behaviour; Alt bypasses. Guides are
* cleared by `FloorplanRegistryMoveOverlay`'s Path 1 teardown.
* Figma alignment is layered on the stair footprint edges; Alt bypasses.
* Guides are cleared by `FloorplanRegistryMoveOverlay`'s Path 1 teardown.
*
* The position is written straight to scene each tick (the stair has a real
* `position` field, unlike polygon kinds) and re-applied atomically via
@@ -29,6 +27,10 @@ import { applyFloorplanAlignment, getSegmentGridStep } from '@pascal-app/editor'
*/
export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node, nodes }) => {
const startY = node.position[1]
const resolveCursor = createFloorplanCursorResolver({
original: [node.position[0], node.position[2]],
metadata: node.metadata,
})
// Alignment candidates gathered once — the scene is stable during the drag.
const candidates = collectAlignmentAnchors(nodes, node.id)
let lastValid: { position: [number, number, number] } | null = null
@@ -39,13 +41,16 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node,
// Snap the origin to the editor's current grid step (driven by
// `useEditor.gridSnapStep`). Shift bypasses the grid snap.
const step = getSegmentGridStep()
const gx = modifiers.shiftKey ? planPoint[0] : snapScalar(planPoint[0], step)
const gz = modifiers.shiftKey ? planPoint[1] : snapScalar(planPoint[1], step)
// Figma alignment on the origin point (Alt bypasses), matching the 3D
// move tool. Publishes guides via `useAlignmentGuides`.
const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step))
const [gx, gz] = resolveCursor(planPoint, { snap })
// Figma alignment on the actual stair footprint (Alt bypasses),
// matching the 3D move tool. Publishes guides via `useAlignmentGuides`.
const movingAnchors = movingAlignmentAnchors(node, nodes, gx, gz, node.rotation ?? 0)
const { point: aligned } = applyFloorplanAlignment(
[gx, gz],
[{ nodeId: node.id, kind: 'corner', x: gx, z: gz }],
movingAnchors.length > 0
? movingAnchors
: [{ nodeId: node.id, kind: 'corner', x: gx, z: gz }],
candidates,
{ bypass: modifiers.altKey },
)
+19 -2
View File
@@ -3,10 +3,16 @@ import {
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
useScene,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
import { snapToHalf } from '@pascal-app/editor'
import { findClosestWallInPlan, snapLocalXToNeighbors } from '../shared/wall-attach-target'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import {
findClosestWallInPlan,
projectWallLocalPointToPlan,
snapLocalXToNeighbors,
} from '../shared/wall-attach-target'
import { clampToWall, hasWallChildOverlap } from './window-math'
/**
@@ -26,6 +32,16 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
const wall = useScene.getState().nodes[node.parentId as AnyNodeId]
return wall ? (wall.parentId as AnyNodeId | null) : null
})()
const originalWall = node.parentId
? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined)
: undefined
const resolveCursor = createFloorplanCursorResolver({
original:
originalWall?.type === 'wall'
? projectWallLocalPointToPlan(originalWall, node.position[0])
: [node.position[0], 0],
metadata: node.metadata,
})
// Preserve the source window's local Y — 2D move doesn't have a way
// to express vertical motion, so we keep whatever vertical position
@@ -46,7 +62,8 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) {
const nodes = useScene.getState().nodes
const hit = findClosestWallInPlan(planPoint, nodes, startLevelId)
const resolvedPlanPoint = resolveCursor(planPoint)
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
if (!hit) return
// Figma-style along-wall alignment first (edge-to-edge with other