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
+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)