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:
Aymeric Rabot
2026-06-05 16:24:48 -04:00
committed by GitHub
parent d1b40aa98d
commit 0b338cf647
51 changed files with 3942 additions and 1418 deletions
+25 -1
View File
@@ -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
}
+27 -5
View File
@@ -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()
}
}
+10 -3
View File
@@ -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) {