Improve editor manipulation flows
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user