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
+42 -6
View File
@@ -5,6 +5,7 @@ import {
type FenceNode,
type GridEvent,
type LevelNode,
nodeRegistry,
type RoofNode,
type RoofSegmentNode,
resolveAlignment,
@@ -19,6 +20,7 @@ import {
import {
CursorSphere,
clearRoofDuplicateMetadata,
getFloorStackPreviewPosition,
snapFenceDraftPoint,
triggerSFX,
useEditor,
@@ -113,6 +115,11 @@ export const MoveRoofTool: React.FC<{
// Track pending rotation — no store updates during drag
let pendingRotation: number = movingNode.rotation as number
let lastLocalPosition: [number, number, number] = [
movingNode.position[0],
movingNode.position[1],
movingNode.position[2],
]
// For roof-segment moves: the selection was cleared before entering move mode,
// so isSelected=false on the parent roof, hiding individual segment meshes and
@@ -150,6 +157,20 @@ export const MoveRoofTool: React.FC<{
}
const levelId = resolveLevelId()
const isFloorPlaced = nodeRegistry.get(movingNode.type)?.capabilities?.floorPlaced !== undefined
const getPreviewPosition = (
position: [number, number, number],
rotation = pendingRotation,
): [number, number, number] => {
if (!isFloorPlaced) return position
return getFloorStackPreviewPosition({
node: movingNode,
position,
rotation,
levelId,
nodes: useScene.getState().nodes,
})
}
const levelNode =
levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode)
@@ -260,20 +281,28 @@ export const MoveRoofTool: React.FC<{
}
previousGridPosRef.current = [gridX, gridZ]
setCursorWorldPos([lx, event.localPosition[1], lz])
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
lastLocalPosition = [localX, movingNode.position[1], localZ]
const previewPosition = getPreviewPosition(lastLocalPosition)
setCursorWorldPos(isFloorPlaced ? previewPosition : [lx, event.localPosition[1], lz])
// Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id)
if (mesh) {
mesh.position.x = localX
mesh.position.z = localZ
if (isFloorPlaced) {
mesh.position.set(...previewPosition)
} else {
mesh.position.x = localX
mesh.position.z = localZ
}
}
// Publish world-space position so the 2D floorplan can track the drag
// Publish canonical position so the 2D floorplan can track the drag.
// Floor-placed parents (stairs) stay in their committed local frame;
// the lifted Y remains presentation-only in the 3D view.
useLiveTransforms.getState().set(movingNode.id, {
position: [gridX, y, gridZ],
position: isFloorPlaced ? lastLocalPosition : [gridX, y, gridZ],
rotation: pendingRotation,
})
}
@@ -359,7 +388,14 @@ export const MoveRoofTool: React.FC<{
// Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id)
if (mesh) mesh.rotation.y = pendingRotation
if (mesh) {
mesh.rotation.y = pendingRotation
if (isFloorPlaced) {
const previewPosition = getPreviewPosition(lastLocalPosition, pendingRotation)
mesh.position.set(...previewPosition)
setCursorWorldPos(previewPosition)
}
}
// Update live transform rotation for 2D floorplan
const currentLive = useLiveTransforms.getState().get(movingNode.id)