polished placements
This commit is contained in:
@@ -91,12 +91,14 @@ export const wallStrategy = {
|
||||
/**
|
||||
* Handle wall:enter — transition from floor to wall surface.
|
||||
* Returns null if item doesn't attach to walls, face is invalid, or wrong level.
|
||||
* Auto-adjusts Y position to fit within wall bounds.
|
||||
*/
|
||||
enter(
|
||||
ctx: PlacementContext,
|
||||
event: WallEvent,
|
||||
resolveLevelId: LevelResolver,
|
||||
nodes: Record<string, AnyNode>,
|
||||
validators: SpatialValidators,
|
||||
): TransitionResult | null {
|
||||
const attachTo = ctx.asset.attachTo
|
||||
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
|
||||
@@ -114,16 +116,30 @@ export const wallStrategy = {
|
||||
const y = snapToHalf(event.localPosition[1])
|
||||
const z = snapToHalf(event.localPosition[2])
|
||||
|
||||
// Get auto-adjusted Y position from validator
|
||||
const validation = validators.canPlaceOnWall(
|
||||
ctx.levelId,
|
||||
event.node.id,
|
||||
x,
|
||||
y,
|
||||
ctx.asset.dimensions ?? DEFAULT_DIMENSIONS,
|
||||
attachTo,
|
||||
side,
|
||||
[],
|
||||
)
|
||||
|
||||
const adjustedY = validation.adjustedY ?? y
|
||||
|
||||
return {
|
||||
stateUpdate: { surface: 'wall', wallId: event.node.id },
|
||||
nodeUpdate: {
|
||||
position: [x, y, z],
|
||||
position: [x, adjustedY, z],
|
||||
parentId: event.node.id,
|
||||
side,
|
||||
rotation: [0, itemRotation, 0],
|
||||
},
|
||||
cursorRotationY: cursorRotation,
|
||||
gridPosition: [x, y, z],
|
||||
gridPosition: [x, adjustedY, z],
|
||||
cursorPosition: [
|
||||
snapToHalf(event.position[0]),
|
||||
snapToHalf(event.position[1]),
|
||||
@@ -136,22 +152,37 @@ export const wallStrategy = {
|
||||
/**
|
||||
* Handle wall:move — update position while on wall.
|
||||
* Returns null if not on a wall or face is invalid.
|
||||
* Auto-adjusts Y position to fit within wall bounds.
|
||||
*/
|
||||
move(ctx: PlacementContext, event: WallEvent): PlacementResult | null {
|
||||
move(ctx: PlacementContext, event: WallEvent, validators: SpatialValidators): PlacementResult | null {
|
||||
if (ctx.state.surface !== 'wall') return null
|
||||
if (!ctx.draftItem) return null
|
||||
if (!ctx.draftItem || !ctx.levelId) return null
|
||||
if (!isValidWallSideFace(event.normal)) return null
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
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(
|
||||
ctx.levelId,
|
||||
event.node.id,
|
||||
snappedX,
|
||||
snappedY,
|
||||
ctx.draftItem.asset.dimensions,
|
||||
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
|
||||
side,
|
||||
[ctx.draftItem.id],
|
||||
)
|
||||
|
||||
const adjustedY = validation.adjustedY ?? snappedY
|
||||
|
||||
return {
|
||||
gridPosition: [
|
||||
snapToHalf(event.localPosition[0]),
|
||||
snapToHalf(event.localPosition[1]),
|
||||
snapToHalf(event.localPosition[2]),
|
||||
],
|
||||
gridPosition: [snappedX, adjustedY, snappedZ],
|
||||
cursorPosition: [
|
||||
snapToHalf(event.position[0]),
|
||||
snapToHalf(event.position[1]),
|
||||
@@ -159,6 +190,7 @@ export const wallStrategy = {
|
||||
],
|
||||
cursorRotationY: cursorRotation,
|
||||
nodeUpdate: {
|
||||
position: [snappedX, adjustedY, snappedZ],
|
||||
side,
|
||||
rotation: [0, itemRotation, 0],
|
||||
},
|
||||
|
||||
@@ -93,7 +93,7 @@ export interface SpatialValidators {
|
||||
attachType: 'wall' | 'wall-side',
|
||||
side?: 'front' | 'back',
|
||||
ignoreIds?: string[],
|
||||
) => { valid: boolean }
|
||||
) => { valid: boolean; adjustedY?: number; wasAdjusted?: boolean }
|
||||
canPlaceOnCeiling: (
|
||||
ceilingId: CeilingNode['id'],
|
||||
position: [number, number, number],
|
||||
|
||||
@@ -213,7 +213,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
const nodes = useScene.getState().nodes
|
||||
const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
||||
const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, validators)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
@@ -235,7 +235,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
if (ctx.state.surface !== 'wall') {
|
||||
const nodes = useScene.getState().nodes
|
||||
const enterResult = wallStrategy.enter(ctx, event, resolveLevelId, nodes)
|
||||
const enterResult = wallStrategy.enter(ctx, event, resolveLevelId, nodes, validators)
|
||||
if (!enterResult) return
|
||||
|
||||
event.stopPropagation()
|
||||
@@ -251,7 +251,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
if (!draftNode.current) {
|
||||
const nodes = useScene.getState().nodes
|
||||
const setup = wallStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
||||
const setup = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, validators)
|
||||
if (!setup) return
|
||||
|
||||
event.stopPropagation()
|
||||
@@ -259,7 +259,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
return
|
||||
}
|
||||
|
||||
const result = wallStrategy.move(ctx, event)
|
||||
const result = wallStrategy.move(ctx, event, validators)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
@@ -318,7 +318,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
if (configRef.current.onCommitted()) {
|
||||
const nodes = useScene.getState().nodes
|
||||
const enterResult = wallStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
||||
const enterResult = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, validators)
|
||||
if (enterResult) {
|
||||
applyTransition(enterResult)
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,9 @@ type AttachType = 'wall' | 'wall-side'
|
||||
// Small tolerance for floating point comparison to allow adjacent items
|
||||
const EPSILON = 0.001
|
||||
|
||||
// Margin from ceiling/floor when auto-snapping items
|
||||
const AUTO_SNAP_MARGIN = 0.05
|
||||
|
||||
interface WallItemPlacement {
|
||||
itemId: string
|
||||
wallId: string
|
||||
@@ -15,12 +18,42 @@ interface WallItemPlacement {
|
||||
side?: WallSide // Which side for 'wall-side' items (undefined means both for 'wall')
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-adjust Y position to fit item within wall bounds
|
||||
* Returns the adjusted Y position (bottom of item)
|
||||
*/
|
||||
function autoAdjustYPosition(
|
||||
yBottom: number,
|
||||
itemHeight: number,
|
||||
wallHeight: number,
|
||||
): { adjustedY: number; wasAdjusted: boolean } {
|
||||
const yTop = yBottom + itemHeight
|
||||
|
||||
// If fits perfectly, no adjustment needed
|
||||
if (yBottom >= 0 && yTop <= wallHeight) {
|
||||
return { adjustedY: yBottom, wasAdjusted: false }
|
||||
}
|
||||
|
||||
// If too high (top exceeds wall height), snap down from ceiling
|
||||
if (yTop > wallHeight) {
|
||||
const adjustedY = wallHeight - itemHeight - AUTO_SNAP_MARGIN
|
||||
return { adjustedY: Math.max(0, adjustedY), wasAdjusted: true }
|
||||
}
|
||||
|
||||
// If too low (bottom below floor), snap up from floor
|
||||
if (yBottom < 0) {
|
||||
return { adjustedY: AUTO_SNAP_MARGIN, wasAdjusted: true }
|
||||
}
|
||||
|
||||
return { adjustedY: yBottom, wasAdjusted: false }
|
||||
}
|
||||
|
||||
export class WallSpatialGrid {
|
||||
private wallItems = new Map<string, WallItemPlacement[]>() // wallId -> placements
|
||||
private itemToWall = new Map<string, string>() // itemId -> wallId (reverse lookup)
|
||||
|
||||
/**
|
||||
* Check if an item can be placed on a wall
|
||||
* Check if an item can be placed on a wall with auto-adjustment for vertical position
|
||||
* @param wallId - The wall to place on
|
||||
* @param wallLength - Length of the wall
|
||||
* @param wallHeight - Height of the wall
|
||||
@@ -31,6 +64,7 @@ export class WallSpatialGrid {
|
||||
* @param attachType - 'wall' (blocks both sides) or 'wall-side' (blocks one side)
|
||||
* @param side - Which side for 'wall-side' items
|
||||
* @param ignoreIds - Item IDs to ignore in conflict check
|
||||
* @returns Validation result with auto-adjusted Y position if needed
|
||||
*/
|
||||
canPlaceOnWall(
|
||||
wallId: string,
|
||||
@@ -43,19 +77,21 @@ export class WallSpatialGrid {
|
||||
attachType: AttachType = 'wall',
|
||||
side?: WallSide,
|
||||
ignoreIds: string[] = [],
|
||||
): { valid: boolean; conflictIds: string[] } {
|
||||
): { valid: boolean; conflictIds: string[]; adjustedY: number; wasAdjusted: boolean } {
|
||||
const halfW = itemWidth / wallLength / 2
|
||||
const tStart = tCenter - halfW
|
||||
const tEnd = tCenter + halfW
|
||||
// yBottom is the bottom of the item, so yEnd = yBottom + itemHeight
|
||||
const yStart = yBottom
|
||||
const yEnd = yBottom + itemHeight
|
||||
|
||||
// Check wall boundaries
|
||||
if (tStart < 0 || tEnd > 1 || yStart < 0 || yEnd > wallHeight) {
|
||||
return { valid: false, conflictIds: [] }
|
||||
// Check horizontal boundaries (still reject if item exceeds wall width)
|
||||
if (tStart < 0 || tEnd > 1) {
|
||||
return { valid: false, conflictIds: [], adjustedY: yBottom, wasAdjusted: false }
|
||||
}
|
||||
|
||||
// Auto-adjust vertical position to fit within wall bounds
|
||||
const { adjustedY, wasAdjusted } = autoAdjustYPosition(yBottom, itemHeight, wallHeight)
|
||||
const yStart = adjustedY
|
||||
const yEnd = adjustedY + itemHeight
|
||||
|
||||
const existing = this.wallItems.get(wallId) ?? []
|
||||
const ignoreSet = new Set(ignoreIds)
|
||||
const conflicts: string[] = []
|
||||
@@ -76,7 +112,7 @@ export class WallSpatialGrid {
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: conflicts.length === 0, conflictIds: conflicts }
|
||||
return { valid: conflicts.length === 0, conflictIds: conflicts, adjustedY, wasAdjusted }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user