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