Improve editor manipulation flows

This commit is contained in:
Aymeric Rabot
2026-06-08 01:07:53 -04:00
parent ab271df9b6
commit 8dc602caa9
57 changed files with 3442 additions and 735 deletions
+2 -2
View File
@@ -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,
+1
View File
@@ -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')
})
})
+87
View File
@@ -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