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
+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,