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
+21 -8
View File
@@ -1,7 +1,12 @@
'use client'
import { emitter, type GridEvent, SpawnNode, sceneRegistry, useScene } from '@pascal-app/core'
import { CursorSphere, triggerSFX, useEditor } from '@pascal-app/editor'
import {
CursorSphere,
getFloorStackPreviewPosition,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { type Group, Vector3 } from 'three'
@@ -20,16 +25,12 @@ function getExistingSpawnIds() {
function getLevelLocalPosition(levelId: string, event: GridEvent): [number, number, number] {
const levelObject = sceneRegistry.nodes.get(levelId)
if (!levelObject) {
return [
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
return [roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])]
}
worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector)
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
return [roundToHalf(worldVector.x), 0, roundToHalf(worldVector.z)]
}
/**
@@ -53,7 +54,19 @@ const SpawnTool = () => {
// same half-meter snap the legacy tool uses.
const nextX = roundToHalf(event.localPosition[0])
const nextZ = roundToHalf(event.localPosition[2])
cursorRef.current?.position.set(nextX, event.localPosition[1], nextZ)
const position: [number, number, number] = [nextX, 0, nextZ]
const previewNode = SpawnNode.parse({
name: 'Spawn Point',
position,
rotation: 0,
})
const visualPosition = getFloorStackPreviewPosition({
node: previewNode,
position,
rotation: 0,
levelId: activeLevelId,
})
cursorRef.current?.position.set(...visualPosition)
// Fire grid-snap SFX only when the snapped position crosses a cell,
// not every frame the mouse moves within the same cell. Matches the