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,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,
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
])
|
||||
},
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user