polished placements

This commit is contained in:
wass08
2026-02-16 09:12:04 +09:00
parent 13070a5630
commit 052efea2cb
4 changed files with 92 additions and 24 deletions
@@ -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 }
}
/**