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
+25 -1
View File
@@ -19,6 +19,7 @@ const BRACE_HANDLE_OFFSET = 0.3
const SPREAD_HANDLE_OFFSET = 0.22
const ROTATE_CORNER_OFFSET = 0.32
const ROTATE_RING_OFFSET = 0.04
const MOVE_FRONT_OFFSET = 0.35
const MIN_COLUMN_HEIGHT = 0.2
const MIN_COLUMN_WIDTH = 0.1
const MIN_COLUMN_DEPTH = 0.1
@@ -241,6 +242,29 @@ function columnRotateHandle(): HandleDescriptor<ColumnNodeType> {
}
}
function columnMoveHandle(): HandleDescriptor<ColumnNodeType> {
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 { halfZ } = columnFootprintHalf(n)
return [0, 0.02, halfZ + MOVE_FRONT_OFFSET]
},
},
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
snapExtents: (n) => {
const { halfX, halfZ } = columnFootprintHalf(n)
const dimX = Math.max(halfX * 2, MIN_COLUMN_WIDTH)
const dimZ = Math.max(halfZ * 2, MIN_COLUMN_DEPTH)
const swap = Math.abs(Math.sin(n.rotation ?? 0)) > 0.9
return [swap ? dimZ : dimX, swap ? dimX : dimZ]
},
}
}
function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[] {
// 1. Height (universal).
// 2. Footprint arrows depending on supportStyle + crossSection:
@@ -265,7 +289,7 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[]
} else {
handles.push(columnAxisHandle('x'), columnAxisHandle('z'))
}
handles.push(columnRotateHandle())
handles.push(columnRotateHandle(), columnMoveHandle())
return handles
}
+27 -5
View File
@@ -17,6 +17,7 @@ import {
import {
CursorSphere,
DragBoundingBox,
getFloorStackPreviewPosition,
markToolCancelConsumed,
triggerSFX,
useEditor,
@@ -74,6 +75,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
? (node.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
const getVisualPosition = (
position: [number, number, number],
rotation = rotationY,
): [number, number, number] =>
getFloorStackPreviewPosition({
node,
position,
rotation,
levelId: node.parentId ?? null,
})
// Alignment candidates — every other alignable object's anchors, gathered
// once (the scene graph is stable during the imperative drag).
@@ -81,19 +92,23 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
const applyPreview = (position: [number, number, number]) => {
lastPosition = position
setPreviewPosition(position)
const visualPosition = getVisualPosition(position)
setPreviewPosition(visualPosition)
setPreviewRotation(rotationY)
useLiveTransforms.getState().set(node.id, {
position,
rotation: rotationY,
})
useScene.getState().markDirty(node.id as AnyNodeId)
const m = sceneRegistry.nodes.get(node.id)
if (m) {
m.position.set(position[0], position[1], position[2])
m.position.set(...visualPosition)
m.rotation.y = rotationY
}
}
setPreviewPosition(getVisualPosition(node.position, node.rotation))
const onGridMove = (event: GridEvent) => {
hasMoved = true
let x = snapToGridStep(event.localPosition[0])
@@ -145,11 +160,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
if (nodeId && useScene.getState().nodes[nodeId]) {
committed = true
useLiveTransforms.getState().clear(nodeId)
useScene.temporal.getState().resume()
useScene
.getState()
.updateNode(nodeId, { position, rotation: rotationY, ...(isNew ? { metadata: {} } : {}) })
useLiveTransforms.getState().clear(nodeId)
const m = sceneRegistry.nodes.get(nodeId)
if (m) {
m.position.set(...getVisualPosition(position, rotationY))
m.rotation.y = rotationY
}
} else if (node.parentId) {
const column = ColumnNodeSchema.parse({
...node,
@@ -174,9 +194,10 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
useAlignmentGuides.getState().clear()
const m = sceneRegistry.nodes.get(node.id)
if (m) {
m.position.set(node.position[0], node.position[1], node.position[2])
m.position.set(...getVisualPosition(node.position, node.rotation))
m.rotation.y = node.rotation
}
useScene.getState().markDirty(node.id as AnyNodeId)
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
@@ -197,9 +218,10 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
if (!committed) {
const m = sceneRegistry.nodes.get(node.id)
if (m) {
m.position.set(node.position[0], node.position[1], node.position[2])
m.position.set(...getVisualPosition(node.position, node.rotation))
m.rotation.y = node.rotation
}
useScene.getState().markDirty(node.id as AnyNodeId)
useScene.temporal.getState().resume()
}
}
+10 -3
View File
@@ -13,7 +13,7 @@ import {
useAlignmentGuides,
useScene,
} from '@pascal-app/core'
import { triggerSFX, usePlacementPreview } from '@pascal-app/editor'
import { getFloorStackPreviewPosition, triggerSFX, usePlacementPreview } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import type { Group } from 'three'
@@ -91,13 +91,20 @@ const ColumnTool = () => {
useAlignmentGuides.getState().clear()
}
cursorRef.current?.position.set(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)
// Publish a transient, positioned preview node for the 2D floor-plan
// ghost (the 3D `ColumnPreview` mesh is hidden in 2D). The floor-plan
// placement-preview layer renders this node's footprint at the snapped,
// aligned cursor so users see the pillar before they click.
usePlacementPreview.getState().set({ ...previewNode, position: [ax, 0, az] })
usePlacementPreview.getState().set({ ...previewNode, position })
const prev = previousSnapRef.current
if (!prev || prev[0] !== ax || prev[1] !== az) {
+42 -6
View File
@@ -5,6 +5,7 @@ import {
type FenceNode,
type GridEvent,
type LevelNode,
nodeRegistry,
type RoofNode,
type RoofSegmentNode,
resolveAlignment,
@@ -19,6 +20,7 @@ import {
import {
CursorSphere,
clearRoofDuplicateMetadata,
getFloorStackPreviewPosition,
snapFenceDraftPoint,
triggerSFX,
useEditor,
@@ -113,6 +115,11 @@ export const MoveRoofTool: React.FC<{
// Track pending rotation — no store updates during drag
let pendingRotation: number = movingNode.rotation as number
let lastLocalPosition: [number, number, number] = [
movingNode.position[0],
movingNode.position[1],
movingNode.position[2],
]
// For roof-segment moves: the selection was cleared before entering move mode,
// so isSelected=false on the parent roof, hiding individual segment meshes and
@@ -150,6 +157,20 @@ export const MoveRoofTool: React.FC<{
}
const levelId = resolveLevelId()
const isFloorPlaced = nodeRegistry.get(movingNode.type)?.capabilities?.floorPlaced !== undefined
const getPreviewPosition = (
position: [number, number, number],
rotation = pendingRotation,
): [number, number, number] => {
if (!isFloorPlaced) return position
return getFloorStackPreviewPosition({
node: movingNode,
position,
rotation,
levelId,
nodes: useScene.getState().nodes,
})
}
const levelNode =
levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode)
@@ -260,20 +281,28 @@ export const MoveRoofTool: React.FC<{
}
previousGridPosRef.current = [gridX, gridZ]
setCursorWorldPos([lx, event.localPosition[1], lz])
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
lastLocalPosition = [localX, movingNode.position[1], localZ]
const previewPosition = getPreviewPosition(lastLocalPosition)
setCursorWorldPos(isFloorPlaced ? previewPosition : [lx, event.localPosition[1], lz])
// Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id)
if (mesh) {
mesh.position.x = localX
mesh.position.z = localZ
if (isFloorPlaced) {
mesh.position.set(...previewPosition)
} else {
mesh.position.x = localX
mesh.position.z = localZ
}
}
// Publish world-space position so the 2D floorplan can track the drag
// Publish canonical position so the 2D floorplan can track the drag.
// Floor-placed parents (stairs) stay in their committed local frame;
// the lifted Y remains presentation-only in the 3D view.
useLiveTransforms.getState().set(movingNode.id, {
position: [gridX, y, gridZ],
position: isFloorPlaced ? lastLocalPosition : [gridX, y, gridZ],
rotation: pendingRotation,
})
}
@@ -359,7 +388,14 @@ export const MoveRoofTool: React.FC<{
// Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id)
if (mesh) mesh.rotation.y = pendingRotation
if (mesh) {
mesh.rotation.y = pendingRotation
if (isFloorPlaced) {
const previewPosition = getPreviewPosition(lastLocalPosition, pendingRotation)
mesh.position.set(...previewPosition)
setCursorWorldPos(previewPosition)
}
}
// Update live transform rotation for 2D floorplan
const currentLive = useLiveTransforms.getState().get(movingNode.id)
+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) {
+2 -1
View File
@@ -24,6 +24,7 @@ export const SlabHoleEditor: React.FC<{ slabId: SlabNode['id']; holeIndex: numbe
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
const holes = slab?.holes || []
const hole = holes[holeIndex]
const metadata = slab?.holeMetadata?.[holeIndex]
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
@@ -60,7 +61,7 @@ export const SlabHoleEditor: React.FC<{ slabId: SlabNode['id']; holeIndex: numbe
}
}, [slabId])
if (!(slab && hole) || hole.length < 3) return null
if (!(slab && hole) || hole.length < 3 || metadata?.source !== 'manual') return null
return (
<PolygonEditor
+17 -1
View File
@@ -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',
+15 -4
View File
@@ -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]} />
+21 -8
View File
@@ -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
+116 -1
View File
@@ -1,8 +1,10 @@
import {
type HandleDescriptor,
type NodeDefinition,
type SceneApi,
StairNode as StairNodeSchema,
type StairNode as StairNodeType,
type StairSegmentNode,
stairFootprintAABB,
} from '@pascal-app/core'
@@ -27,6 +29,7 @@ const CURVED_INNER_RING_MIN = 0.05
// the footprint. Same pattern as elevator / column / shelf / roof-segment.
const STAIR_ROTATE_CORNER_OFFSET = 0.4
const STAIR_ROTATE_RING_OFFSET = 0.08
const STAIR_MOVE_FRONT_OFFSET = 0.35
type CurvedStairGeom = {
isSpiral: boolean
@@ -42,6 +45,14 @@ type CurvedStairGeom = {
minInnerRadius: number
}
type StairMoveBounds = {
minX: number
maxX: number
minZ: number
maxZ: number
height: number
}
function readCurvedStairGeometry(node: StairNodeType): CurvedStairGeom {
const isSpiral = node.stairType === 'spiral'
const stepCount = Math.max(2, Math.round(node.stepCount ?? 10))
@@ -71,6 +82,79 @@ function isCurvedOrSpiral(node: StairNodeType): boolean {
return node.stairType === 'curved' || node.stairType === 'spiral'
}
function rotateLocalXZ(x: number, z: number, angle: number): [number, number] {
const cos = Math.cos(angle)
const sin = Math.sin(angle)
return [x * cos + z * sin, -x * sin + z * cos]
}
function fallbackStraightStairMoveBounds(node: StairNodeType): StairMoveBounds {
const width = Math.max(node.width ?? 1, MIN_CURVED_WIDTH)
const depth = Math.max(width, 1)
return {
minX: -width / 2,
maxX: width / 2,
minZ: 0,
maxZ: depth,
height: Math.max(node.totalRise ?? 2.5, 0.1),
}
}
function readStraightStairMoveBounds(node: StairNodeType, sceneApi: SceneApi): StairMoveBounds {
const segments = (node.children ?? [])
.map((childId) => sceneApi.get<StairSegmentNode>(childId as never))
.filter((child): child is StairSegmentNode => child?.type === 'stair-segment')
if (segments.length === 0) return fallbackStraightStairMoveBounds(node)
const transforms = computeStairSegmentFloorStackTransforms(segments)
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
let height = 0
segments.forEach((segment, index) => {
const transform = transforms[index]
if (!transform) return
const halfWidth = segment.width / 2
const corners = [
[-halfWidth, 0],
[halfWidth, 0],
[-halfWidth, segment.length],
[halfWidth, segment.length],
] as const
for (const [x, z] of corners) {
const [rx, rz] = rotateLocalXZ(x, z, transform.rotation)
minX = Math.min(minX, transform.position[0] + rx)
maxX = Math.max(maxX, transform.position[0] + rx)
minZ = Math.min(minZ, transform.position[2] + rz)
maxZ = Math.max(maxZ, transform.position[2] + rz)
}
height = Math.max(
height,
transform.position[1] + Math.max(segment.height, segment.thickness, 0.01),
)
})
if (![minX, maxX, minZ, maxZ].every(Number.isFinite)) {
return fallbackStraightStairMoveBounds(node)
}
return { minX, maxX, minZ, maxZ, height: Math.max(height, 0.1) }
}
function readStairMoveBounds(node: StairNodeType, sceneApi: SceneApi): StairMoveBounds {
if (!isCurvedOrSpiral(node)) return readStraightStairMoveBounds(node, sceneApi)
const g = readCurvedStairGeometry(node)
return {
minX: -g.outerRadius,
maxX: g.outerRadius,
minZ: -g.outerRadius,
maxZ: g.outerRadius,
height: g.totalRise,
}
}
function curvedRiseHandle(): HandleDescriptor<StairNodeType> {
return {
kind: 'linear-resize',
@@ -266,6 +350,29 @@ function stairRotateHandle(): HandleDescriptor<StairNodeType> {
}
}
function stairMoveHandle(): HandleDescriptor<StairNodeType> {
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, sceneApi) => {
const bounds = readStairMoveBounds(n, sceneApi)
return [(bounds.minX + bounds.maxX) / 2, 0.02, bounds.maxZ + STAIR_MOVE_FRONT_OFFSET]
},
},
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
snapExtents: (n, sceneApi) => {
const bounds = readStairMoveBounds(n, sceneApi)
const dimX = Math.max(bounds.maxX - bounds.minX, MIN_CURVED_WIDTH)
const dimZ = Math.max(bounds.maxZ - bounds.minZ, MIN_CURVED_WIDTH)
const swap = Math.abs(Math.sin(n.rotation ?? 0)) > 0.9
return [swap ? dimZ : dimX, swap ? dimX : dimZ]
},
}
}
function stairHandles(node: StairNodeType): HandleDescriptor<StairNodeType>[] {
// Straight stairs have no parent-level shape arrows — the segment
// children each render their own (width / length / height). Curved +
@@ -282,10 +389,14 @@ function stairHandles(node: StairNodeType): HandleDescriptor<StairNodeType>[] {
curvedSweepHandle('end'),
)
}
handles.push(stairRotateHandle())
handles.push(stairRotateHandle(), stairMoveHandle())
return handles
}
import {
computeStairSegmentFloorStackTransforms,
getStairFloorPlacedFootprints,
} from './floor-stack'
import { buildStairFloorplan } from './floorplan'
import {
curvedStairInnerRadiusAffordance,
@@ -330,6 +441,10 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
},
duplicable: true,
deletable: true,
floorPlaced: {
footprints: (node, ctx) =>
ctx ? getStairFloorPlacedFootprints(node as StairNodeType, ctx.nodes) : [],
},
},
// Bespoke move shared with roof / roof-segment / stair-segment via
@@ -0,0 +1,177 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeDefinition,
getFloorPlacedElevation,
nodeRegistry,
registerNode,
type SlabNode,
StairNode,
StairSegmentNode,
spatialGridManager,
} from '@pascal-app/core'
import { stairDefinition } from './definition'
import { getStairSegmentFloorPlacedFootprints } from './floor-stack'
const LEVEL_ID = 'level_test'
function makeLevel(): AnyNode {
return {
id: LEVEL_ID,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children: [],
level: 0,
} as AnyNode
}
function addSlab(polygon: Array<[number, number]>, elevation: number, id = `slab_${elevation}`) {
const slab = {
id,
type: 'slab',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
polygon,
holes: [],
holeMetadata: [],
elevation,
autoFromWalls: false,
} as SlabNode
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
}
describe('stair floor-stack footprints', () => {
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
})
test('derives a rotated segment footprint from stair position and rotation', () => {
const segment = StairSegmentNode.parse({
id: 'sseg_single',
width: 2,
length: 4,
height: 1,
thickness: 0.25,
})
const stair = StairNode.parse({
id: 'stair_single',
parentId: LEVEL_ID,
position: [10, 0, 20],
rotation: Math.PI / 2,
children: [segment.id],
})
const [footprint] = getStairSegmentFloorPlacedFootprints(stair, [segment])
expect(footprint?.position?.[0]).toBeCloseTo(12)
expect(footprint?.position?.[1]).toBeCloseTo(0)
expect(footprint?.position?.[2]).toBeCloseTo(20)
expect(footprint?.dimensions).toEqual([2, 1, 4])
expect(footprint?.rotation[1]).toBeCloseTo(Math.PI / 2)
})
test('emits one footprint per chained stair segment', () => {
const first = StairSegmentNode.parse({
id: 'sseg_first',
width: 2,
length: 4,
height: 1,
thickness: 0.25,
})
const second = StairSegmentNode.parse({
id: 'sseg_second',
attachmentSide: 'left',
width: 1.5,
length: 3,
height: 0.8,
thickness: 0.2,
})
const stair = StairNode.parse({
id: 'stair_multi',
parentId: LEVEL_ID,
position: [0, 0, 0],
rotation: 0,
children: [first.id, second.id],
})
const footprints = getStairSegmentFloorPlacedFootprints(stair, [first, second])
expect(footprints).toHaveLength(2)
expect(footprints[0]?.position).toEqual([0, 0, 2])
expect(footprints[0]?.rotation[1]).toBeCloseTo(0)
expect(footprints[1]?.position?.[0]).toBeCloseTo(2.5)
expect(footprints[1]?.position?.[1]).toBeCloseTo(1)
expect(footprints[1]?.position?.[2]).toBeCloseTo(2)
expect(footprints[1]?.rotation[1]).toBeCloseTo(Math.PI / 2)
})
test('uses the max slab elevation across stair segment footprints', () => {
registerNode(stairDefinition as unknown as AnyNodeDefinition)
addSlab(
[
[-0.6, 1.4],
[0.6, 1.4],
[0.6, 2.6],
[-0.6, 2.6],
],
0.25,
'slab_low',
)
addSlab(
[
[2.2, 1.7],
[2.8, 1.7],
[2.8, 2.3],
[2.2, 2.3],
],
0.75,
'slab_high',
)
const level = makeLevel()
const first = StairSegmentNode.parse({
id: 'sseg_resolver_first',
width: 2,
length: 4,
height: 1,
})
const second = StairSegmentNode.parse({
id: 'sseg_resolver_second',
attachmentSide: 'left',
width: 1.5,
length: 3,
height: 0.8,
})
const stair = StairNode.parse({
id: 'stair_resolver',
parentId: LEVEL_ID,
position: [0, 0, 0],
rotation: 0,
children: [first.id, second.id],
})
const nodes = {
[level.id]: level,
[stair.id]: stair,
[first.id]: first,
[second.id]: second,
}
expect(
getFloorPlacedElevation({
node: stair,
nodes,
position: stair.position,
rotation: stair.rotation,
levelId: LEVEL_ID,
}),
).toBeCloseTo(0.75)
})
})
+114
View File
@@ -0,0 +1,114 @@
import type {
AnyNode,
AnyNodeId,
FloorPlacedFootprint,
StairNode,
StairSegmentNode,
} from '@pascal-app/core'
type SegmentTransform = {
position: [number, number, number]
rotation: number
}
export function getStairFloorPlacedFootprints(
stair: StairNode,
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
): FloorPlacedFootprint[] {
const segments = (stair.children ?? [])
.map((childId) => nodes[childId as AnyNodeId])
.filter((node): node is StairSegmentNode => node?.type === 'stair-segment')
return getStairSegmentFloorPlacedFootprints(stair, segments)
}
export function getStairSegmentFloorPlacedFootprints(
stair: StairNode,
segments: readonly StairSegmentNode[],
): FloorPlacedFootprint[] {
const transforms = computeStairSegmentFloorStackTransforms(segments)
return segments.map((segment, index) => {
const transform = transforms[index]!
const [centerOffsetX, centerOffsetZ] = rotateXZ(0, segment.length / 2, transform.rotation)
const centerInGroupX = transform.position[0] + centerOffsetX
const centerInGroupZ = transform.position[2] + centerOffsetZ
const [centerOffsetWorldX, centerOffsetWorldZ] = rotateXZ(
centerInGroupX,
centerInGroupZ,
stair.rotation,
)
return {
position: [
stair.position[0] + centerOffsetWorldX,
stair.position[1] + transform.position[1],
stair.position[2] + centerOffsetWorldZ,
],
dimensions: [
segment.width,
Math.max(segment.height, segment.thickness, 0.01),
segment.length,
],
rotation: [0, stair.rotation + transform.rotation, 0],
}
})
}
export function computeStairSegmentFloorStackTransforms(
segments: readonly StairSegmentNode[],
): SegmentTransform[] {
const transforms: SegmentTransform[] = []
let currentX = 0
let currentY = 0
let currentZ = 0
let currentRot = 0
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index]!
if (index > 0) {
const previous = segments[index - 1]!
let attachX = 0
let attachZ = 0
let rotationDelta = 0
switch (segment.attachmentSide) {
case 'front':
attachX = 0
attachZ = previous.length
rotationDelta = 0
break
case 'left':
attachX = previous.width / 2
attachZ = previous.length / 2
rotationDelta = Math.PI / 2
break
case 'right':
attachX = -previous.width / 2
attachZ = previous.length / 2
rotationDelta = -Math.PI / 2
break
}
const [rotatedX, rotatedZ] = rotateXZ(attachX, attachZ, currentRot)
currentX += rotatedX
currentY += previous.height
currentZ += rotatedZ
currentRot += rotationDelta
}
transforms.push({
position: [currentX, currentY, currentZ],
rotation: currentRot,
})
}
return transforms
}
function rotateXZ(x: number, z: number, angle: number): [number, number] {
const cos = Math.cos(angle)
const sin = Math.sin(angle)
return [x * cos + z * sin, -x * sin + z * cos]
}