Improve editor manipulation flows
This commit is contained in:
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user