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
+32 -3
View File
@@ -1,4 +1,5 @@
import type { HandleDescriptor, NodeDefinition, ShelfNode as ShelfNodeType } from '@pascal-app/core'
import { sanitizeShelfDimensions } from './dimensions'
import { buildShelfFloorplan } from './floorplan'
import { shelfResizeAffordance, shelfRotateAffordance } from './floorplan-affordances'
import { shelfFloorplanMoveTarget } from './floorplan-move'
@@ -10,6 +11,7 @@ const SIDE_HANDLE_OFFSET = 0.18
const HEIGHT_HANDLE_OFFSET = 0.22
const ROTATE_CORNER_OFFSET = 0.32
const ROTATE_RING_OFFSET = 0.04
const MOVE_FRONT_OFFSET = 0.35
const MIN_SHELF_WIDTH = 0.3
const MIN_SHELF_DEPTH = 0.1
const MIN_SHELF_HEIGHT = 0.05
@@ -95,8 +97,35 @@ function shelfRotateHandle(): HandleDescriptor<ShelfNodeType> {
}
}
function shelfMoveHandle(): HandleDescriptor<ShelfNodeType> {
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 shelf = sanitizeShelfDimensions(n as ShelfNode)
return [0, 0.02, shelf.depth / 2 + MOVE_FRONT_OFFSET]
},
},
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
snapExtents: (n) => {
const shelf = sanitizeShelfDimensions(n as ShelfNode)
const swap = Math.abs(Math.sin(shelf.rotation[1] ?? 0)) > 0.9
return [swap ? shelf.depth : shelf.width, swap ? shelf.width : shelf.depth]
},
}
}
function shelfHandles(_node: ShelfNodeType): HandleDescriptor<ShelfNodeType>[] {
return [shelfWidthHandle(), shelfDepthHandle(), shelfHeightHandle(), shelfRotateHandle()]
return [
shelfWidthHandle(),
shelfDepthHandle(),
shelfHeightHandle(),
shelfRotateHandle(),
shelfMoveHandle(),
]
}
export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
@@ -158,7 +187,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
// shelf sitting over a raised slab visually rests on top of it.
floorPlaced: {
footprint: (node) => {
const shelf = node as ShelfNode
const shelf = sanitizeShelfDimensions(node as ShelfNode)
return {
dimensions: [shelf.width, shelf.height, shelf.depth] as [number, number, number],
rotation: shelf.rotation,
@@ -189,7 +218,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
// `children`. Lets <GeometrySystem> skip the dispose+rebuild (and the
// pointer enter/leave churn it causes) when an item reparents onto a row.
geometryKey: (n) => {
const s = n as ShelfNodeType
const s = sanitizeShelfDimensions(n as ShelfNode)
return JSON.stringify([
s.style,
s.width,