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,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
|
||||
|
||||
Reference in New Issue
Block a user