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:
@@ -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',
|
||||
|
||||
@@ -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]} />
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user