Improve editor manipulation flows
This commit is contained in:
@@ -1286,8 +1286,8 @@ export type FloorPlacedConfig = {
|
||||
* serves both the static candidate and the moving node.
|
||||
* - `aabb` — an already-resolved XZ bounding box, for kinds whose plan
|
||||
* shape isn't a centred rectangle (stair: a segment chain or annular
|
||||
* sector). Static candidates only — these kinds move by their origin, so
|
||||
* the box's relocation path never needs them.
|
||||
* sector). The moving-anchor bridge can relocate these by patching the
|
||||
* proposed plan position and resolving the AABB again.
|
||||
*
|
||||
* `nodes` is supplied only when a kind needs siblings / children to resolve
|
||||
* its footprint (a straight stair walks its `stair-segment` children); box
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
collectAlignmentAnchors,
|
||||
footprintAABB,
|
||||
footprintAABBFrom,
|
||||
movingAlignmentAnchors,
|
||||
movingFootprintAnchors,
|
||||
polygonAnchors,
|
||||
wallSegmentAnchors,
|
||||
@@ -196,6 +197,71 @@ describe('movingFootprintAnchors', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('movingAlignmentAnchors', () => {
|
||||
beforeEach(() => nodeRegistry._reset())
|
||||
|
||||
test('relocates a straight stair by its segment-chain footprint', () => {
|
||||
registerNode(stairDef())
|
||||
const nodes = {
|
||||
st: node({
|
||||
id: 'st',
|
||||
type: 'stair',
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
stairType: 'straight',
|
||||
width: 1,
|
||||
children: ['seg'],
|
||||
}),
|
||||
seg: node({
|
||||
id: 'seg',
|
||||
type: 'stair-segment',
|
||||
parentId: 'st',
|
||||
width: 1,
|
||||
length: 3,
|
||||
height: 2.5,
|
||||
attachmentSide: 'front',
|
||||
}),
|
||||
}
|
||||
|
||||
const anchors = movingAlignmentAnchors(nodes.st, nodes, 10, 20, 0)
|
||||
expect(anchors).toHaveLength(4)
|
||||
expect(new Set(anchors.map((a) => a.x))).toEqual(new Set([9.5, 10.5]))
|
||||
expect(new Set(anchors.map((a) => a.z))).toEqual(new Set([20, 23]))
|
||||
})
|
||||
|
||||
test('rotation override drives a moving straight stair footprint', () => {
|
||||
registerNode(stairDef())
|
||||
const nodes = {
|
||||
st: node({
|
||||
id: 'st',
|
||||
type: 'stair',
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
stairType: 'straight',
|
||||
width: 1,
|
||||
children: ['seg'],
|
||||
}),
|
||||
seg: node({
|
||||
id: 'seg',
|
||||
type: 'stair-segment',
|
||||
parentId: 'st',
|
||||
width: 1,
|
||||
length: 3,
|
||||
height: 2.5,
|
||||
attachmentSide: 'front',
|
||||
}),
|
||||
}
|
||||
|
||||
const anchors = movingAlignmentAnchors(nodes.st, nodes, 10, 20, Math.PI / 2)
|
||||
const xs = anchors.map((a) => a.x)
|
||||
const zs = anchors.map((a) => a.z)
|
||||
expect(Math.min(...xs)).toBeCloseTo(10, 10)
|
||||
expect(Math.max(...xs)).toBeCloseTo(13, 10)
|
||||
expect(Math.min(...zs)).toBeCloseTo(19.5, 10)
|
||||
expect(Math.max(...zs)).toBeCloseTo(20.5, 10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wallSegmentAnchors', () => {
|
||||
test('returns both endpoints as corners and the chord midpoint as center', () => {
|
||||
const anchors = wallSegmentAnchors('w', [0, 0], [4, 2])
|
||||
|
||||
@@ -152,6 +152,61 @@ export function movingFootprintAnchors(
|
||||
return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ)
|
||||
}
|
||||
|
||||
function relocatedPlanNode(node: AnyNode, x: number, z: number, rotationY?: number): AnyNode {
|
||||
const position = (node as { position?: unknown }).position
|
||||
const y = Array.isArray(position) && typeof position[1] === 'number' ? position[1] : 0
|
||||
const relocated: Record<string, unknown> = {
|
||||
...(node as Record<string, unknown>),
|
||||
position: [x, y, z],
|
||||
}
|
||||
|
||||
if (rotationY !== undefined && 'rotation' in node) {
|
||||
const rotation = (node as { rotation?: unknown }).rotation
|
||||
relocated.rotation = Array.isArray(rotation)
|
||||
? [rotation[0] ?? 0, rotationY, rotation[2] ?? 0]
|
||||
: rotationY
|
||||
}
|
||||
|
||||
return relocated as AnyNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Corner anchors for a moving node relocated to the proposed plan position.
|
||||
* Covers both the centred-box path (`floorPlaced.footprint` /
|
||||
* `alignmentFootprint: box`) and explicit AABB footprints such as stairs,
|
||||
* whose occupied plan bounds depend on children or curved/spiral geometry.
|
||||
*/
|
||||
export function movingAlignmentAnchors(
|
||||
node: AnyNode,
|
||||
nodes: Readonly<Record<string, AnyNode>> | undefined,
|
||||
x: number,
|
||||
z: number,
|
||||
rotationY?: number,
|
||||
): AlignmentAnchor[] {
|
||||
const box = footprintAABBAt(node, x, z, rotationY)
|
||||
if (box) return bboxCornerAnchors(node.id, box.minX, box.minZ, box.maxX, box.maxZ)
|
||||
|
||||
const alignment = nodeRegistry
|
||||
.get(node.type)
|
||||
?.capabilities?.alignmentFootprint?.(relocatedPlanNode(node, x, z, rotationY), nodes)
|
||||
|
||||
if (alignment?.shape === 'box') {
|
||||
const aabb = footprintAABBFrom([x, 0, z], alignment.dimensions, alignment.rotation[1] ?? 0)
|
||||
return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ)
|
||||
}
|
||||
if (alignment?.shape === 'aabb') {
|
||||
return bboxCornerAnchors(
|
||||
node.id,
|
||||
alignment.minX,
|
||||
alignment.minZ,
|
||||
alignment.maxX,
|
||||
alignment.maxZ,
|
||||
)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Alignment anchors for a wall segment: the two centerline endpoints + chord
|
||||
* midpoint, plus — when `thickness` is known — four **face** corner anchors,
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
footprintAABB,
|
||||
footprintAABBAt,
|
||||
footprintAABBFrom,
|
||||
movingAlignmentAnchors,
|
||||
movingFootprintAnchors,
|
||||
nodeAlignmentAnchors,
|
||||
polygonAnchors,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import type { AnyNode } from '../schema'
|
||||
import useScene from './use-scene'
|
||||
|
||||
describe('scene elevator migrations', () => {
|
||||
beforeEach(() => {
|
||||
useScene.setState({
|
||||
nodes: {},
|
||||
rootNodeIds: [],
|
||||
dirtyNodes: new Set(),
|
||||
collections: {},
|
||||
} as never)
|
||||
useScene.temporal.getState().clear()
|
||||
})
|
||||
|
||||
test('normalizes legacy level-parented elevators into building-scoped nodes', () => {
|
||||
useScene.getState().setScene(
|
||||
{
|
||||
site_test: {
|
||||
object: 'node',
|
||||
id: 'site_test',
|
||||
type: 'site',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: ['building_test'],
|
||||
},
|
||||
building_test: {
|
||||
object: 'node',
|
||||
id: 'building_test',
|
||||
type: 'building',
|
||||
parentId: 'site_test',
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: ['level_test'],
|
||||
},
|
||||
level_test: {
|
||||
object: 'node',
|
||||
id: 'level_test',
|
||||
type: 'level',
|
||||
parentId: 'building_test',
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: ['elevator_test'],
|
||||
level: 0,
|
||||
},
|
||||
elevator_test: {
|
||||
object: 'node',
|
||||
id: 'elevator_test',
|
||||
type: 'elevator',
|
||||
parentId: 'level_test',
|
||||
visible: true,
|
||||
metadata: {},
|
||||
},
|
||||
} as unknown as Record<string, AnyNode>,
|
||||
['site_test'] as never,
|
||||
)
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
const elevator = nodes.elevator_test as Extract<AnyNode, { type: 'elevator' }>
|
||||
const level = nodes.level_test as Extract<AnyNode, { type: 'level' }>
|
||||
const building = nodes.building_test as Extract<AnyNode, { type: 'building' }>
|
||||
|
||||
expect(elevator.parentId).toBe('building_test')
|
||||
expect(elevator.position).toEqual([0, 0, 0])
|
||||
expect(elevator.rotation).toBe(0)
|
||||
expect(level.children).not.toContain('elevator_test')
|
||||
expect(building.children).toContain('elevator_test')
|
||||
})
|
||||
|
||||
test('migrates level-parented elevators when the level parentId is missing', () => {
|
||||
useScene.getState().setScene(
|
||||
{
|
||||
site_test: {
|
||||
object: 'node',
|
||||
id: 'site_test',
|
||||
type: 'site',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: ['building_test'],
|
||||
},
|
||||
building_test: {
|
||||
object: 'node',
|
||||
id: 'building_test',
|
||||
type: 'building',
|
||||
parentId: 'site_test',
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: ['level_test'],
|
||||
},
|
||||
level_test: {
|
||||
object: 'node',
|
||||
id: 'level_test',
|
||||
type: 'level',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: ['elevator_test'],
|
||||
level: 0,
|
||||
},
|
||||
elevator_test: {
|
||||
object: 'node',
|
||||
id: 'elevator_test',
|
||||
type: 'elevator',
|
||||
parentId: 'level_test',
|
||||
visible: true,
|
||||
metadata: {},
|
||||
},
|
||||
} as unknown as Record<string, AnyNode>,
|
||||
['site_test'] as never,
|
||||
)
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
const elevator = nodes.elevator_test as Extract<AnyNode, { type: 'elevator' }>
|
||||
const level = nodes.level_test as Extract<AnyNode, { type: 'level' }>
|
||||
const building = nodes.building_test as Extract<AnyNode, { type: 'building' }>
|
||||
|
||||
expect(elevator.parentId).toBe('building_test')
|
||||
expect(level.children).not.toContain('elevator_test')
|
||||
expect(building.children).toContain('elevator_test')
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { BuildingNode } from '../schema'
|
||||
import type { Collection, CollectionId } from '../schema/collections'
|
||||
import { generateCollectionId } from '../schema/collections'
|
||||
import { DoorNode as DoorNodeSchema } from '../schema/nodes/door'
|
||||
import { ElevatorNode as ElevatorNodeSchema } from '../schema/nodes/elevator'
|
||||
import { LevelNode } from '../schema/nodes/level'
|
||||
import {
|
||||
getPitchFromActiveRoofHeight,
|
||||
@@ -149,6 +150,84 @@ function normalizeShelfNode(node: Record<string, unknown>) {
|
||||
return parsed.success ? parsed.data : null
|
||||
}
|
||||
|
||||
function normalizeElevatorNode(node: Record<string, unknown>) {
|
||||
const sanitized = {
|
||||
...node,
|
||||
position: getVector3(node.position, [0, 0, 0]),
|
||||
rotation: getFiniteNumber(node.rotation, 0),
|
||||
width: getFiniteNumber(node.width, 1.84),
|
||||
depth: getFiniteNumber(node.depth, 1.84),
|
||||
shaftWidth: node.shaftWidth === undefined ? undefined : getFiniteNumber(node.shaftWidth, 1.84),
|
||||
shaftDepth: node.shaftDepth === undefined ? undefined : getFiniteNumber(node.shaftDepth, 1.84),
|
||||
shaftWallThickness: getFiniteNumber(node.shaftWallThickness, 0.09),
|
||||
cabHeight: getFiniteNumber(node.cabHeight, 2.35),
|
||||
doorWidth: getFiniteNumber(node.doorWidth, 0.95),
|
||||
doorHeight: getFiniteNumber(node.doorHeight, 2.1),
|
||||
fromLevelId: getNullableString(node.fromLevelId),
|
||||
toLevelId: getNullableString(node.toLevelId),
|
||||
servedLevelIds:
|
||||
node.servedLevelIds === undefined ? undefined : getStringArray(node.servedLevelIds),
|
||||
disabledLevelIds: getStringArray(node.disabledLevelIds),
|
||||
serviceOnlyLevelIds: getStringArray(node.serviceOnlyLevelIds),
|
||||
defaultLevelId: getNullableString(node.defaultLevelId),
|
||||
speed: getFiniteNumber(node.speed, 2.2),
|
||||
doorDurationMs: getFiniteNumber(node.doorDurationMs, 900),
|
||||
dwellMs: getFiniteNumber(node.dwellMs, 1400),
|
||||
}
|
||||
|
||||
const parsed = ElevatorNodeSchema.safeParse(sanitized)
|
||||
return parsed.success ? parsed.data : null
|
||||
}
|
||||
|
||||
function findBuildingIdForLevel(levelId: string, nodes: Record<string, any>): string | null {
|
||||
const level = nodes[levelId]
|
||||
const directBuildingId = typeof level?.parentId === 'string' ? level.parentId : null
|
||||
if (directBuildingId && nodes[directBuildingId]?.type === 'building') {
|
||||
return directBuildingId
|
||||
}
|
||||
|
||||
for (const [candidateId, candidate] of Object.entries(nodes)) {
|
||||
if (candidate?.type !== 'building') continue
|
||||
if (getStringArray(candidate.children).includes(levelId)) {
|
||||
return candidateId
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function migrateElevatorParent(
|
||||
id: string,
|
||||
node: Record<string, unknown>,
|
||||
nodes: Record<string, any>,
|
||||
) {
|
||||
const parentId = typeof node.parentId === 'string' ? node.parentId : null
|
||||
if (!parentId) return node
|
||||
const parent = parentId ? nodes[parentId] : null
|
||||
if (parent?.type !== 'level') return node
|
||||
|
||||
const buildingId = findBuildingIdForLevel(parentId, nodes)
|
||||
if (!buildingId) return node
|
||||
const building = buildingId ? nodes[buildingId] : null
|
||||
if (building?.type !== 'building') return node
|
||||
|
||||
nodes[parentId] = {
|
||||
...parent,
|
||||
children: getStringArray(parent.children).filter((childId) => childId !== id),
|
||||
}
|
||||
|
||||
const buildingChildren = getStringArray(building.children)
|
||||
nodes[buildingId] = {
|
||||
...building,
|
||||
children: buildingChildren.includes(id) ? buildingChildren : [...buildingChildren, id],
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
parentId: buildingId,
|
||||
}
|
||||
}
|
||||
|
||||
function migrateWallSurfaceMaterials(node: Record<string, any>) {
|
||||
const hasInterior =
|
||||
node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string'
|
||||
@@ -440,6 +519,14 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'elevator') {
|
||||
const parentMigrated = migrateElevatorParent(id, node, patchedNodes)
|
||||
const normalized = normalizeElevatorNode(parentMigrated)
|
||||
if (normalized) {
|
||||
patchedNodes[id] = normalized
|
||||
}
|
||||
}
|
||||
|
||||
// Roof-segment hosting was added in this migration cycle (the same
|
||||
// pattern as shelf above). Older segments saved before the schema
|
||||
// gained `children` need the field initialised so
|
||||
|
||||
Reference in New Issue
Block a user