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,
+18
View File
@@ -0,0 +1,18 @@
import type { ShelfNode } from './schema'
function clampShelfDim(value: unknown, lo: number, hi: number, fallback: number): number {
const v = typeof value === 'number' && Number.isFinite(value) ? value : fallback
return Math.min(Math.max(v, lo), hi)
}
export function sanitizeShelfDimensions(node: ShelfNode): ShelfNode {
return {
...node,
width: clampShelfDim(node.width, 0.3, 3.0, 1.2),
depth: clampShelfDim(node.depth, 0.1, 1.0, 0.3),
thickness: clampShelfDim(node.thickness, 0.01, 0.1, 0.04),
height: clampShelfDim(node.height, 0.05, 2.5, 0.9),
rows: Math.round(clampShelfDim(node.rows, 1, 8, 1)),
columns: Math.round(clampShelfDim(node.columns, 1, 6, 1)),
}
}
+8 -1
View File
@@ -10,6 +10,7 @@ import {
} from '@pascal-app/core'
import {
applyFloorplanAlignment,
getFloorStackPreviewPosition,
snapPointToGrid,
triggerSFX,
type WallPlanPoint,
@@ -82,6 +83,12 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
triggerSFX('sfx:grid-snap')
lastSnapKey = snapKey
}
const visualPosition = getFloorStackPreviewPosition({
node,
position: next,
rotation: node.rotation,
levelId: node.parentId ?? null,
})
// Single source of truth — write the absolute position straight to
// the scene (history is paused by the overlay). Both the 2D SVG and
// the 3D group transform read `node.position` reactively, so they
@@ -90,7 +97,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
useScene.getState().updateNodes([
{
id: shelfId,
data: { position: next },
data: { position: visualPosition },
},
])
},
+14 -12
View File
@@ -1,4 +1,5 @@
import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core'
import { sanitizeShelfDimensions } from './dimensions'
import type { ShelfResizePayload } from './floorplan-affordances'
import type { ShelfNode } from './schema'
@@ -25,15 +26,16 @@ const ROTATE_ARROW_CORNER_OFFSET = 0.22
* (engaged from the action-menu Move button, not from these arrows).
*/
export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): FloorplanGeometry {
const [px, , pz] = node.position
const ry = node.rotation[1] ?? 0
const shelf = sanitizeShelfDimensions(node)
const [px, , pz] = shelf.position
const ry = shelf.rotation[1] ?? 0
// Floor-plan plots at `-ry` so SVG's CW-with-y-down `rotate` direction
// ends up visually matching Three.js Y-rotation (CCW from a top-down
// view) — same `rotation` value rotates the same way in both views.
// Stair already does this; column / shelf / roof-segment now do too.
const planRy = -ry
const halfW = node.width / 2
const halfD = node.depth / 2
const halfW = shelf.width / 2
const halfD = shelf.depth / 2
const isSelected = ctx?.viewState?.selected ?? false
// Floor-plan fill: a single neutral fill regardless of `material`.
@@ -44,8 +46,8 @@ export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): Flo
kind: 'rect',
x: -halfW,
y: -halfD,
width: node.width,
height: node.depth,
width: shelf.width,
height: shelf.depth,
fill: '#d6d3d1',
stroke: '#1f2937',
strokeWidth: 0.015,
@@ -55,17 +57,17 @@ export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): Flo
// Show column dividers for grid-style shelves so the cubby / bookshelf
// grid is visible from above.
if ((node.style === 'bookshelf' || node.style === 'cubby') && node.columns > 1) {
const innerWidth = node.width - 2 * node.thickness
const colStep = innerWidth / node.columns
for (let c = 1; c < node.columns; c++) {
if ((shelf.style === 'bookshelf' || shelf.style === 'cubby') && shelf.columns > 1) {
const innerWidth = shelf.width - 2 * shelf.thickness
const colStep = innerWidth / shelf.columns
for (let c = 1; c < shelf.columns; c++) {
const x = -innerWidth / 2 + c * colStep
footprintChildren.push({
kind: 'line',
x1: x,
y1: -halfD + node.thickness,
y1: -halfD + shelf.thickness,
x2: x,
y2: halfD - node.thickness,
y2: halfD - shelf.thickness,
stroke: '#1f2937',
strokeWidth: 0.012,
opacity: 0.7,
+7 -4
View File
@@ -7,6 +7,7 @@ import {
type RenderShading,
} from '@pascal-app/viewer'
import { BoxGeometry, FrontSide, Group, type Material, Mesh } from 'three'
import { sanitizeShelfDimensions } from './dimensions'
import type { ShelfNode } from './schema'
/**
@@ -69,10 +70,11 @@ function getShelfMaterial(node: ShelfNode, shading: RenderShading): Material {
}
export function buildShelfGeometry(
node: ShelfNode,
rawNode: ShelfNode,
_ctx?: unknown,
shading: RenderShading = 'rendered',
): Group {
const node = sanitizeShelfDimensions(rawNode)
const group = new Group()
group.name = 'shelf-geometry'
@@ -343,8 +345,9 @@ function addCornerPosts(
* can host in the lowest cell.
*/
export function shelfRowSurfaceYs(node: ShelfNode): number[] {
const ys = boardCenterYs(node).map((y) => y + node.thickness / 2)
const bottomApplies = node.style === 'cubby' || node.style === 'bookshelf'
if (node.withBottom && bottomApplies) ys.unshift(node.thickness)
const safe = sanitizeShelfDimensions(node)
const ys = boardCenterYs(safe).map((y) => y + safe.thickness / 2)
const bottomApplies = safe.style === 'cubby' || safe.style === 'bookshelf'
if (safe.withBottom && bottomApplies) ys.unshift(safe.thickness)
return ys
}
+13 -6
View File
@@ -15,7 +15,7 @@ import {
useAlignmentGuides,
useScene,
} from '@pascal-app/core'
import { triggerSFX } from '@pascal-app/editor'
import { getFloorStackPreviewPosition, triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import { type Group, Vector3 } from 'three'
@@ -70,16 +70,16 @@ function getLevelLocalPosition(
const local = (event as GridEvent).localPosition
if (local) {
const [sx, sz] = snapPointToGrid([local[0], local[2]], GRID_STEP)
return [sx, local[1] ?? 0, sz]
return [sx, 0, sz]
}
const [sx, sz] = snapPointToGrid([event.position[0], event.position[2]], GRID_STEP)
return [sx, event.position[1], sz]
return [sx, 0, sz]
}
worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector)
const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], GRID_STEP)
return [sx, worldVector.y, sz]
return [sx, 0, sz]
}
const ShelfTool = () => {
@@ -150,8 +150,15 @@ const ShelfTool = () => {
useAlignmentGuides.getState().clear()
}
cursorRef.current?.position.set(ax, event.localPosition[1], az)
lastCursorRef.current = [ax, event.localPosition[1], az]
const position: [number, number, number] = [ax, 0, az]
const visualPosition = getFloorStackPreviewPosition({
node: previewNode,
position,
rotation: previewNode.rotation,
levelId: activeLevelId,
})
cursorRef.current?.position.set(...visualPosition)
lastCursorRef.current = position
const prev = previousSnapRef.current
if (!prev || prev[0] !== ax || prev[1] !== az) {