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