feat(editor): live floor-stacking, unified handle system, slab-hole editing + interaction polish (#375)
- Live slab-stacking Y previews for all floor-placed kinds (item/shelf/spawn/column/stair) during placement + both move pathways, via a shared core resolver; canonical positions unchanged. - Unified 3D handle system (one drag pipeline + one visual primitive) with forgiving invisible hit-areas on every handle, kept on EDITOR_LAYER so they don't poison the MRT scene pass. - Hover + click-to-edit slab holes in 3D (manual hole -> hole editor; stair/elevator hole -> select owner); generic cross-arrow polygon-move grip; normalized handle interaction colors. - NaN-safe node mutations + non-finite shadow-light bounds guard. - Built on #373 (level-scoped alignment / registry slab tool); #373 owns X/Z alignment, this owns Y floor-stacking.
This commit is contained in:
@@ -19,6 +19,7 @@ const BRACE_HANDLE_OFFSET = 0.3
|
||||
const SPREAD_HANDLE_OFFSET = 0.22
|
||||
const ROTATE_CORNER_OFFSET = 0.32
|
||||
const ROTATE_RING_OFFSET = 0.04
|
||||
const MOVE_FRONT_OFFSET = 0.35
|
||||
const MIN_COLUMN_HEIGHT = 0.2
|
||||
const MIN_COLUMN_WIDTH = 0.1
|
||||
const MIN_COLUMN_DEPTH = 0.1
|
||||
@@ -241,6 +242,29 @@ function columnRotateHandle(): HandleDescriptor<ColumnNodeType> {
|
||||
}
|
||||
}
|
||||
|
||||
function columnMoveHandle(): HandleDescriptor<ColumnNodeType> {
|
||||
return {
|
||||
kind: 'translate',
|
||||
placement: {
|
||||
// Low to the floor at the front edge (matches the item move grip) so it
|
||||
// reads as a floor-move grip and stays clear of the body resize / rotate
|
||||
// handles that sit at mid-height.
|
||||
position: (n) => {
|
||||
const { halfZ } = columnFootprintHalf(n)
|
||||
return [0, 0.02, halfZ + MOVE_FRONT_OFFSET]
|
||||
},
|
||||
},
|
||||
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
|
||||
snapExtents: (n) => {
|
||||
const { halfX, halfZ } = columnFootprintHalf(n)
|
||||
const dimX = Math.max(halfX * 2, MIN_COLUMN_WIDTH)
|
||||
const dimZ = Math.max(halfZ * 2, MIN_COLUMN_DEPTH)
|
||||
const swap = Math.abs(Math.sin(n.rotation ?? 0)) > 0.9
|
||||
return [swap ? dimZ : dimX, swap ? dimX : dimZ]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[] {
|
||||
// 1. Height (universal).
|
||||
// 2. Footprint arrows depending on supportStyle + crossSection:
|
||||
@@ -265,7 +289,7 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[]
|
||||
} else {
|
||||
handles.push(columnAxisHandle('x'), columnAxisHandle('z'))
|
||||
}
|
||||
handles.push(columnRotateHandle())
|
||||
handles.push(columnRotateHandle(), columnMoveHandle())
|
||||
return handles
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import {
|
||||
CursorSphere,
|
||||
DragBoundingBox,
|
||||
getFloorStackPreviewPosition,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
@@ -74,6 +75,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
? (node.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
const getVisualPosition = (
|
||||
position: [number, number, number],
|
||||
rotation = rotationY,
|
||||
): [number, number, number] =>
|
||||
getFloorStackPreviewPosition({
|
||||
node,
|
||||
position,
|
||||
rotation,
|
||||
levelId: node.parentId ?? null,
|
||||
})
|
||||
|
||||
// Alignment candidates — every other alignable object's anchors, gathered
|
||||
// once (the scene graph is stable during the imperative drag).
|
||||
@@ -81,19 +92,23 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
|
||||
const applyPreview = (position: [number, number, number]) => {
|
||||
lastPosition = position
|
||||
setPreviewPosition(position)
|
||||
const visualPosition = getVisualPosition(position)
|
||||
setPreviewPosition(visualPosition)
|
||||
setPreviewRotation(rotationY)
|
||||
useLiveTransforms.getState().set(node.id, {
|
||||
position,
|
||||
rotation: rotationY,
|
||||
})
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
const m = sceneRegistry.nodes.get(node.id)
|
||||
if (m) {
|
||||
m.position.set(position[0], position[1], position[2])
|
||||
m.position.set(...visualPosition)
|
||||
m.rotation.y = rotationY
|
||||
}
|
||||
}
|
||||
|
||||
setPreviewPosition(getVisualPosition(node.position, node.rotation))
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
hasMoved = true
|
||||
let x = snapToGridStep(event.localPosition[0])
|
||||
@@ -145,11 +160,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
|
||||
if (nodeId && useScene.getState().nodes[nodeId]) {
|
||||
committed = true
|
||||
useLiveTransforms.getState().clear(nodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(nodeId, { position, rotation: rotationY, ...(isNew ? { metadata: {} } : {}) })
|
||||
useLiveTransforms.getState().clear(nodeId)
|
||||
const m = sceneRegistry.nodes.get(nodeId)
|
||||
if (m) {
|
||||
m.position.set(...getVisualPosition(position, rotationY))
|
||||
m.rotation.y = rotationY
|
||||
}
|
||||
} else if (node.parentId) {
|
||||
const column = ColumnNodeSchema.parse({
|
||||
...node,
|
||||
@@ -174,9 +194,10 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
const m = sceneRegistry.nodes.get(node.id)
|
||||
if (m) {
|
||||
m.position.set(node.position[0], node.position[1], node.position[2])
|
||||
m.position.set(...getVisualPosition(node.position, node.rotation))
|
||||
m.rotation.y = node.rotation
|
||||
}
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
@@ -197,9 +218,10 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
if (!committed) {
|
||||
const m = sceneRegistry.nodes.get(node.id)
|
||||
if (m) {
|
||||
m.position.set(node.position[0], node.position[1], node.position[2])
|
||||
m.position.set(...getVisualPosition(node.position, node.rotation))
|
||||
m.rotation.y = node.rotation
|
||||
}
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { triggerSFX, usePlacementPreview } from '@pascal-app/editor'
|
||||
import { getFloorStackPreviewPosition, triggerSFX, usePlacementPreview } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import type { Group } from 'three'
|
||||
@@ -91,13 +91,20 @@ const ColumnTool = () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
cursorRef.current?.position.set(ax, event.localPosition[1], az)
|
||||
const position: [number, number, number] = [ax, 0, az]
|
||||
const visualPosition = getFloorStackPreviewPosition({
|
||||
node: previewNode,
|
||||
position,
|
||||
rotation: previewNode.rotation,
|
||||
levelId: activeLevelId,
|
||||
})
|
||||
cursorRef.current?.position.set(...visualPosition)
|
||||
|
||||
// Publish a transient, positioned preview node for the 2D floor-plan
|
||||
// ghost (the 3D `ColumnPreview` mesh is hidden in 2D). The floor-plan
|
||||
// placement-preview layer renders this node's footprint at the snapped,
|
||||
// aligned cursor so users see the pillar before they click.
|
||||
usePlacementPreview.getState().set({ ...previewNode, position: [ax, 0, az] })
|
||||
usePlacementPreview.getState().set({ ...previewNode, position })
|
||||
|
||||
const prev = previousSnapRef.current
|
||||
if (!prev || prev[0] !== ax || prev[1] !== az) {
|
||||
|
||||
Reference in New Issue
Block a user