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
@@ -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`.