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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user