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
+17 -1
View File
@@ -1,8 +1,23 @@
import type { NodeDefinition } from '@pascal-app/core'
import type { HandleDescriptor, NodeDefinition, SpawnNode as SpawnNodeType } from '@pascal-app/core'
import { buildSpawnFloorplan } from './floorplan'
import { spawnParametrics } from './parametrics'
import { SpawnNode } from './schema'
const SPAWN_FOOTPRINT = 0.6
const MOVE_FRONT_OFFSET = 0.35
function spawnMoveHandle(): HandleDescriptor<SpawnNodeType> {
return {
kind: 'translate',
placement: {
// Low to the floor at the front edge (matches the item move grip).
position: () => [0, 0.02, SPAWN_FOOTPRINT / 2 + MOVE_FRONT_OFFSET],
},
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
snapExtents: () => [SPAWN_FOOTPRINT, SPAWN_FOOTPRINT],
}
}
export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
kind: 'spawn',
schemaVersion: 1,
@@ -37,6 +52,7 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
},
parametrics: spawnParametrics,
handles: [spawnMoveHandle()],
renderer: {
kind: 'parametric',
+15 -4
View File
@@ -1,6 +1,12 @@
'use client'
import { type SpawnNode, useLiveTransforms, useRegistry } from '@pascal-app/core'
import {
type AnyNodeId,
type SpawnNode,
useLiveNodeOverrides,
useLiveTransforms,
useRegistry,
} from '@pascal-app/core'
import { createDefaultMaterial, useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useMemo, useRef } from 'react'
import { Color, type Group, Shape } from 'three'
@@ -20,6 +26,11 @@ const SPAWN_COLOR = new Color('#22c55e')
const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
const ref = useRef<Group>(null!)
const handlers = useNodeEvents(node, 'spawn')
const liveOverride = useLiveNodeOverrides((state) => state.get(node.id as AnyNodeId))
const effectiveNode = useMemo(
() => (liveOverride ? ({ ...node, ...liveOverride } as SpawnNode) : node),
[node, liveOverride],
)
const liveTransform = useLiveTransforms((state) => state.get(node.id))
const walkthroughMode = useViewer((state) => state.walkthroughMode)
const shading = useViewer((state) => state.shading)
@@ -52,10 +63,10 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
return (
<group
position={liveTransform?.position ?? node.position}
position={liveTransform?.position ?? effectiveNode.position}
ref={ref}
rotation={[0, liveTransform?.rotation ?? node.rotation, 0]}
visible={!walkthroughMode}
rotation={[0, liveTransform?.rotation ?? effectiveNode.rotation, 0]}
visible={!walkthroughMode && effectiveNode.visible !== false}
>
<mesh position={[0, 0.09, 0]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}>
<ringGeometry args={[0.34, 0.48, 48]} />
+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