Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions packages/editor/src/components/tools/item/placement-strategies.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { describe, expect, test } from 'bun:test'
import type { ItemNode, WallEvent, WallNode } from '@pascal-app/core'
import { Mesh, type Object3D, Vector3 } from 'three'
import { wallStrategy } from './placement-strategies'
import type { PlacementContext, SpatialValidators } from './placement-types'

/**
* The wall frame the runtime builds in `updateWallGeometry`: origin at
* `wall.start` lifted to the supporting slab's elevation, yawed by the wall
* angle. Wall-local Y is therefore measured from the slab, NOT from world zero
* — which is exactly why snapping the world hit and the wall-local hit
* separately used to put the preview box and the item on different points.
*/
function makeWallFrame(wall: WallNode, slabElevation: number): Mesh {
const wallMesh = new Mesh()
wallMesh.position.set(wall.start[0], slabElevation, wall.start[1])
wallMesh.rotation.y = -Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
const collisionMesh = new Mesh()
wallMesh.add(collisionMesh)
wallMesh.updateMatrixWorld(true)
return collisionMesh
}

function makeWall(overrides: Partial<WallNode> = {}): WallNode {
return {
id: 'wall_test',
type: 'wall',
parentId: 'level_test',
children: [],
start: [2.3, 1.7],
end: [8.3, 1.7],
thickness: 0.2,
...overrides,
} as WallNode
}

function makeDraft(): ItemNode {
return {
id: 'item_draft',
type: 'item',
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
children: [],
asset: {
id: 'asset_hold',
category: 'sport',
name: 'Climbing hold',
thumbnail: '',
source: 'library',
src: '',
dimensions: [0.65, 0.33, 0.63],
attachTo: 'wall-side',
offset: [0, 0.165, 0.1],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
} as unknown as ItemNode
}

/** Front face of the wall: wall-local +Z. */
const FRONT_NORMAL: [number, number, number] = [0, 0, 1]

function makeWallEvent(wall: WallNode, collisionMesh: Object3D, localHit: Vector3): WallEvent {
const world = collisionMesh.localToWorld(localHit.clone())
return {
node: wall,
position: [world.x, world.y, world.z],
localPosition: [localHit.x, localHit.y, localHit.z],
normal: FRONT_NORMAL,
object: collisionMesh,
stopPropagation: () => undefined,
} as unknown as WallEvent
}

const validators: SpatialValidators = {
canPlaceOnFloor: () => ({ valid: true }),
canPlaceOnWall: () => ({ valid: true }),
canPlaceOnCeiling: () => ({ valid: true }),
}

function makeContext(draft: ItemNode): PlacementContext {
return {
asset: draft.asset,
levelId: 'level_test',
draftItem: draft,
gridPosition: new Vector3(),
state: {
surface: 'wall',
wallId: 'wall_test',
roofSegmentId: null,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
},
currentCursorRotationY: 0,
} as unknown as PlacementContext
}

describe('wallStrategy.move', () => {
/**
* The preview wireframe (`cursorPosition`, world) and the committed node
* (`gridPosition`, wall-local) must describe ONE point. Snapping them
* independently drifted the box off the item by the slab elevation plus up to
* a grid step, and the commit then landed where the box was not.
*/
test.each([
['axis-aligned wall on an elevated slab', makeWall(), 0.4],
[
'diagonal wall off the world grid',
makeWall({ start: [1.15, 0.35], end: [5.15, 4.35] } as Partial<WallNode>),
0.15,
],
])('keeps the preview box on the committed point — %s', (_label, wall, slabElevation) => {
const collisionMesh = makeWallFrame(wall, slabElevation)
const draft = makeDraft()
const event = makeWallEvent(wall, collisionMesh, new Vector3(2.42, 1.38, 0.1))

const result = wallStrategy.move(makeContext(draft), event, validators)
if (!result) throw new Error('expected a placement result')

const cursorFromNode = collisionMesh.localToWorld(new Vector3(...result.gridPosition))
expect(cursorFromNode.x).toBeCloseTo(result.cursorPosition[0], 6)
expect(cursorFromNode.y).toBeCloseTo(result.cursorPosition[1], 6)
expect(cursorFromNode.z).toBeCloseTo(result.cursorPosition[2], 6)
})

test('mounts the wall-side preview on the hit face, not through the wall', () => {
const wall = makeWall()
const collisionMesh = makeWallFrame(wall, 0.4)
const draft = makeDraft()
const event = makeWallEvent(wall, collisionMesh, new Vector3(2.42, 1.38, 0.1))

const result = wallStrategy.move(makeContext(draft), event, validators)
if (!result) throw new Error('expected a placement result')

// Front face → wall-local +thickness/2, matching ItemSystem's per-frame push.
expect(result.gridPosition[2]).toBeCloseTo(0.1, 6)
// The cursor frame IS the item frame, so the box's +Z (its depth) points out
// of the same face the item body extends from.
const outward = new Vector3(0, 0, 1).applyAxisAngle(
new Vector3(0, 1, 0),
result.cursorRotationY,
)
const wallNormal = new Vector3(0, 0, 1).applyAxisAngle(
new Vector3(0, 1, 0),
collisionMesh.parent!.rotation.y,
)
expect(outward.dot(wallNormal)).toBeCloseTo(1, 6)
})

test('carries the wall auto-adjusted Y into the preview box', () => {
const wall = makeWall()
const collisionMesh = makeWallFrame(wall, 0.4)
const draft = makeDraft()
const event = makeWallEvent(wall, collisionMesh, new Vector3(2.42, 1.38, 0.1))

const result = wallStrategy.move(makeContext(draft), event, {
...validators,
canPlaceOnWall: () => ({ valid: true, adjustedY: 0.05, wasAdjusted: true }),
})
if (!result) throw new Error('expected a placement result')

expect(result.gridPosition[1]).toBeCloseTo(0.05, 6)
expect(result.cursorPosition[1]).toBeCloseTo(0.4 + 0.05, 6)
})
})
79 changes: 57 additions & 22 deletions packages/editor/src/components/tools/item/placement-strategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit'
import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
import {
calculateCursorRotation,
calculateItemRotation,
getGridAlignedDimensions,
getSideFromNormal,
Expand Down Expand Up @@ -178,6 +177,51 @@ export const floorStrategy = {
// WALL STRATEGY
// ============================================================================

/**
* Resolve the wall-local node position AND the world pose of the placement
* wireframe from ONE wall-local point, so the box can't drift from the item it
* previews.
*
* `event.object` is the wall's collision mesh — the frame `event.localPosition`
* was measured in — so `localToWorld` is the exact inverse of the hit. Snapping
* the raw world hit per axis instead (the old path) put the box on a different
* lattice than the node: wall-local X runs from `wall.start`, and wall-local Y
* is measured from the supporting slab's elevation, not from world zero.
*
* `z` follows the hosting convention rather than the hit depth: `wall` items
* center in the thickness, `wall-side` items mount on the hit face — mirroring
* `ItemSystem`'s per-frame push, so the wireframe's `z = 0` face lands flush
* with the wall instead of extending through it.
*/
function resolveWallPlacementPose(
event: WallEvent,
localX: number,
localY: number,
attachTo: 'wall' | 'wall-side',
side: 'front' | 'back',
itemRotation: number,
): {
position: [number, number, number]
cursorPosition: [number, number, number]
cursorRotationY: number
} {
const localZ =
attachTo === 'wall-side' ? ((event.node.thickness ?? 0.1) / 2) * (side === 'front' ? 1 : -1) : 0
event.object.updateWorldMatrix(true, false)
const world = event.object.localToWorld(new Vector3(localX, localY, localZ))
const wallYaw = -Math.atan2(
event.node.end[1] - event.node.start[1],
event.node.end[0] - event.node.start[0],
)
return {
position: [localX, localY, localZ],
cursorPosition: [world.x, world.y, world.z],
// Same composition the 2D floorplan resolves a wall child with
// (`resolveItemTransform`): the cursor frame IS the item frame.
cursorRotationY: wallYaw + itemRotation,
}
}

export const wallStrategy = {
/**
* Handle wall:enter — transition from floor to wall surface.
Expand All @@ -201,11 +245,9 @@ export const wallStrategy = {

const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)

const x = snapToHalf(event.localPosition[0])
const y = snapToHalf(event.localPosition[1])
const z = snapToHalf(event.localPosition[2])

// Get auto-adjusted Y position from validator
const rawDims = ctx.draftItem
Expand All @@ -223,25 +265,22 @@ export const wallStrategy = {
)

const adjustedY = validation.adjustedY ?? y
const pose = resolveWallPlacementPose(event, x, adjustedY, attachTo, side, itemRotation)

return {
stateUpdate: { surface: 'wall', wallId: event.node.id, roofSegmentId: null },
nodeUpdate: {
position: [x, adjustedY, z],
position: pose.position,
parentId: event.node.id,
// The draft may arrive from a roof-segment wall face.
roofSegmentId: undefined,
roofFace: undefined,
side,
rotation: [0, itemRotation, 0],
},
cursorRotationY: cursorRotation,
gridPosition: [x, adjustedY, z],
cursorPosition: [
snapToHalf(event.position[0]),
snapToHalf(event.position[1]),
snapToHalf(event.position[2]),
],
cursorRotationY: pose.cursorRotationY,
gridPosition: pose.position,
cursorPosition: pose.cursorPosition,
stopPropagation: true,
}
},
Expand All @@ -262,11 +301,10 @@ export const wallStrategy = {

const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const attachTo = ctx.draftItem.asset.attachTo as 'wall' | 'wall-side'

const snappedX = snapToHalf(event.localPosition[0])
const snappedY = snapToHalf(event.localPosition[1])
const snappedZ = snapToHalf(event.localPosition[2])

// Get auto-adjusted Y position from validator
const validation = validators.canPlaceOnWall(
Expand All @@ -275,23 +313,20 @@ export const wallStrategy = {
snappedX,
snappedY,
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
attachTo,
side,
[ctx.draftItem.id],
)

const adjustedY = validation.adjustedY ?? snappedY
const pose = resolveWallPlacementPose(event, snappedX, adjustedY, attachTo, side, itemRotation)

return {
gridPosition: [snappedX, adjustedY, snappedZ],
cursorPosition: [
snapToHalf(event.position[0]),
snapToHalf(event.position[1]),
snapToHalf(event.position[2]),
],
cursorRotationY: cursorRotation,
gridPosition: pose.position,
cursorPosition: pose.cursorPosition,
cursorRotationY: pose.cursorRotationY,
nodeUpdate: {
position: [snappedX, adjustedY, snappedZ],
position: pose.position,
side,
rotation: [0, itemRotation, 0],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1130,19 +1130,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX)
const correctedY = wallDragAnchor.startY + (rawY - wallDragAnchor.rawY)
const wallMesh = sceneRegistry.nodes.get(event.node.id)
// Derive the world cursor from the corrected wall-local point so the
// visual cursor (world) and the stored position (wall-local) agree; if
// the wall mesh is somehow absent, keep the raw world hit unchanged.
const correctedWorld = wallMesh
? wallMesh.localToWorld(new Vector3(correctedX, correctedY, event.localPosition[2]))
: null
wallMoveEvent = {
...event,
localPosition: [correctedX, correctedY, event.localPosition[2]],
position: correctedWorld
? [correctedWorld.x, correctedWorld.y, correctedWorld.z]
: event.position,
}
}
const result = wallStrategy.move(ctx, wallMoveEvent, getActiveValidators())
Expand Down Expand Up @@ -1204,22 +1194,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea

// Publish live transform for the 2D floorplan. The floorplan resolves a
// wall item's footprint (and its wall-side depth offset) from this
// rotation as a PLAN-space yaw. `cursorRotationY` is the 3D world cursor
// yaw, which is π off from the plan rotation on a wall face — feeding it
// raw flips the footprint to the far side of the wall during placement.
// Publish the plan rotation (wall angle + the item's wall-local yaw) so
// the preview matches what the committed node resolves to.
let liveRotation = result.cursorRotationY
const liveWallId = placementState.current.wallId
const liveWall = liveWallId ? useScene.getState().nodes[liveWallId as AnyNodeId] : undefined
if (liveWall?.type === 'wall') {
const w = liveWall as WallNode
const wallPlanRotation = -Math.atan2(w.end[1] - w.start[1], w.end[0] - w.start[0])
liveRotation = wallPlanRotation + (draft.rotation[1] ?? 0)
}
// rotation as a PLAN-space yaw — which is exactly what the wall strategy
// composes `cursorRotationY` as (wall yaw + the item's wall-local yaw).
useLiveTransforms.getState().set(draft.id, {
position: result.cursorPosition,
rotation: liveRotation,
rotation: result.cursorRotationY,
})
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@ export const FloorElevationSystem = () => {
const position = (effectiveNode as PositionedNode).position
if (!position) return

// `applies === false` means the kind opts OUT of floor stacking for this
// node: its Y belongs to a host frame (a wall/ceiling-mounted item, a
// cabinet module inside a run, a wall duct terminal). `getFloorPlacedElevation`
// already returns 0 for them, so the write below would degenerate to
// copying `position[1]` into the mesh — and tools publish live transforms
// in WORLD space, so during a drag that lifts the ghost off its host by
// the host frame's own elevation.
if (floorPlaced.applies && !floorPlaced.applies(effectiveNode)) return

// This system is the single drag-time authority for floor-stack mesh Y:
// tools publish base positions to live stores, renderers may
// reconcile that base Y onto the group, then this presentation system
Expand Down
Loading