attach to wall side
This commit is contained in:
@@ -36,6 +36,71 @@ const stripTransient = (meta: any) => {
|
|||||||
return rest
|
return rest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate rotation angle from wall normal vector
|
||||||
|
* The normal points outward from the wall surface
|
||||||
|
*/
|
||||||
|
const calculateRotationFromNormal = (normal: [number, number, number] | undefined): number => {
|
||||||
|
if (!normal) return 0
|
||||||
|
// Calculate angle in X-Z plane (top-down view)
|
||||||
|
// atan2(z, x) gives the angle the vector makes with the positive X axis
|
||||||
|
// Add π/2 to align item's forward direction with the wall normal
|
||||||
|
return Math.atan2(normal[2], normal[0]) - Math.PI / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine which side of the wall based on the normal vector
|
||||||
|
* In wall-local space, the wall runs along X-axis, so the normal points along Z-axis
|
||||||
|
* Positive Z normal = 'back', Negative Z normal = 'front' (flipped due to orientation fix)
|
||||||
|
*/
|
||||||
|
const getSideFromNormal = (normal: [number, number, number] | undefined): 'front' | 'back' => {
|
||||||
|
if (!normal) return 'front'
|
||||||
|
// The Z component of the normal determines which side
|
||||||
|
// Flipped: positive Z = back, negative Z = front
|
||||||
|
return normal[2] >= 0 ? 'back' : 'front'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the normal indicates a valid wall side face (front or back)
|
||||||
|
* Filters out top face and thickness edges
|
||||||
|
* @param normal - The face normal vector
|
||||||
|
* @param wallStart - Wall start point [x, z]
|
||||||
|
* @param wallEnd - Wall end point [x, z]
|
||||||
|
*/
|
||||||
|
const isValidWallSideFace = (
|
||||||
|
normal: [number, number, number] | undefined,
|
||||||
|
wallStart: [number, number],
|
||||||
|
wallEnd: [number, number],
|
||||||
|
): boolean => {
|
||||||
|
if (!normal) return false
|
||||||
|
|
||||||
|
// Filter out top/bottom faces (normal pointing up or down)
|
||||||
|
if (Math.abs(normal[1]) > 0.3) return false
|
||||||
|
|
||||||
|
// Calculate wall direction in X-Z plane
|
||||||
|
const wallDirX = wallEnd[0] - wallStart[0]
|
||||||
|
const wallDirZ = wallEnd[1] - wallStart[1]
|
||||||
|
const wallLength = Math.sqrt(wallDirX * wallDirX + wallDirZ * wallDirZ)
|
||||||
|
if (wallLength === 0) return false
|
||||||
|
|
||||||
|
// Normalize wall direction
|
||||||
|
const normWallDirX = wallDirX / wallLength
|
||||||
|
const normWallDirZ = wallDirZ / wallLength
|
||||||
|
|
||||||
|
// Dot product of normal (X-Z components) with wall direction
|
||||||
|
// Front/back faces: normal perpendicular to wall → dot ≈ 0
|
||||||
|
// Thickness edges: normal parallel to wall → dot ≈ ±1
|
||||||
|
const dotProduct = normal[0] * normWallDirX + normal[2] * normWallDirZ
|
||||||
|
|
||||||
|
// If dot product is high, it's a thickness edge (normal parallel to wall)
|
||||||
|
// Allow only faces where normal is mostly perpendicular to wall direction
|
||||||
|
return Math.abs(dotProduct) < 0.5
|
||||||
|
}
|
||||||
|
|
||||||
export const ItemTool: React.FC = () => {
|
export const ItemTool: React.FC = () => {
|
||||||
const cursorRef = useRef<Mesh>(null!)
|
const cursorRef = useRef<Mesh>(null!)
|
||||||
const draftItem = useRef<ItemNode | null>(null)
|
const draftItem = useRef<ItemNode | null>(null)
|
||||||
@@ -65,6 +130,8 @@ export const ItemTool: React.FC = () => {
|
|||||||
gridPosition.current.x,
|
gridPosition.current.x,
|
||||||
gridPosition.current.y,
|
gridPosition.current.y,
|
||||||
draftItem.current.asset.dimensions,
|
draftItem.current.asset.dimensions,
|
||||||
|
draftItem.current.asset.attachTo as 'wall' | 'wall-side',
|
||||||
|
draftItem.current.side,
|
||||||
[draftItem.current.id],
|
[draftItem.current.id],
|
||||||
)
|
)
|
||||||
placeable = result.valid
|
placeable = result.valid
|
||||||
@@ -156,19 +223,33 @@ export const ItemTool: React.FC = () => {
|
|||||||
draftItem.current?.asset.attachTo === 'wall' ||
|
draftItem.current?.asset.attachTo === 'wall' ||
|
||||||
draftItem.current?.asset.attachTo === 'wall-side'
|
draftItem.current?.asset.attachTo === 'wall-side'
|
||||||
) {
|
) {
|
||||||
|
// Ignore top face and thickness edges
|
||||||
|
if (!isValidWallSideFace(event.normal, event.node.start, event.node.end)) return
|
||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
isOnWall.current = true
|
isOnWall.current = true
|
||||||
currentWallId = event.node.id
|
currentWallId = event.node.id
|
||||||
|
|
||||||
|
// Determine side and rotation from normal
|
||||||
|
const side = getSideFromNormal(event.normal)
|
||||||
|
const rotation = calculateRotationFromNormal(event.normal)
|
||||||
|
|
||||||
gridPosition.current.set(
|
gridPosition.current.set(
|
||||||
Math.round(event.localPosition[0] * 2) / 2,
|
Math.round(event.localPosition[0] * 2) / 2,
|
||||||
Math.round(event.localPosition[1] * 2) / 2,
|
Math.round(event.localPosition[1] * 2) / 2,
|
||||||
Math.round(event.localPosition[2] * 2) / 2,
|
Math.round(event.localPosition[2] * 2) / 2,
|
||||||
)
|
)
|
||||||
draftItem.current.parentId = event.node.id
|
draftItem.current.parentId = event.node.id
|
||||||
|
draftItem.current.side = side
|
||||||
|
draftItem.current.rotation = [0, rotation, 0]
|
||||||
|
|
||||||
useScene.getState().updateNode(draftItem.current.id, {
|
useScene.getState().updateNode(draftItem.current.id, {
|
||||||
position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
||||||
parentId: event.node.id,
|
parentId: event.node.id,
|
||||||
|
side,
|
||||||
|
rotation: [0, rotation, 0],
|
||||||
})
|
})
|
||||||
|
cursorRef.current.rotation.y = rotation
|
||||||
checkCanPlace()
|
checkCanPlace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,16 +270,26 @@ export const ItemTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onWallClick = (event: WallEvent) => {
|
const onWallClick = (event: WallEvent) => {
|
||||||
event.stopPropagation()
|
|
||||||
if (!isOnWall.current) return
|
if (!isOnWall.current) return
|
||||||
|
|
||||||
|
// Ignore top face and thickness edges
|
||||||
|
if (!isValidWallSideFace(event.normal, event.node.start, event.node.end)) return
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
const currentLevelId = useViewer.getState().selection.levelId
|
const currentLevelId = useViewer.getState().selection.levelId
|
||||||
if (!currentLevelId || !draftItem.current || !checkCanPlace()) return
|
if (!currentLevelId || !draftItem.current || !checkCanPlace()) return
|
||||||
|
|
||||||
|
// Get side and rotation from current draft item (already set by onWallMove)
|
||||||
|
const side = draftItem.current.side
|
||||||
|
const rotation = draftItem.current.rotation
|
||||||
|
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
useScene.getState().updateNode(draftItem.current.id, {
|
useScene.getState().updateNode(draftItem.current.id, {
|
||||||
position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
||||||
parentId: event.node.id,
|
parentId: event.node.id,
|
||||||
|
side,
|
||||||
|
rotation,
|
||||||
metadata: stripTransient(draftItem.current.metadata),
|
metadata: stripTransient(draftItem.current.metadata),
|
||||||
})
|
})
|
||||||
useScene.getState().dirtyNodes.add(event.node.id)
|
useScene.getState().dirtyNodes.add(event.node.id)
|
||||||
@@ -211,8 +302,17 @@ export const ItemTool: React.FC = () => {
|
|||||||
|
|
||||||
const onWallMove = (event: WallEvent) => {
|
const onWallMove = (event: WallEvent) => {
|
||||||
if (isOnWall.current === false) return
|
if (isOnWall.current === false) return
|
||||||
event.stopPropagation()
|
|
||||||
if (!draftItem.current) return
|
if (!draftItem.current) return
|
||||||
|
|
||||||
|
// Ignore top face and thickness edges
|
||||||
|
if (!isValidWallSideFace(event.normal, event.node.start, event.node.end)) return
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
// Determine side and rotation from normal
|
||||||
|
const side = getSideFromNormal(event.normal)
|
||||||
|
const rotation = calculateRotationFromNormal(event.normal)
|
||||||
|
|
||||||
gridPosition.current.set(
|
gridPosition.current.set(
|
||||||
Math.round(event.localPosition[0] * 2) / 2,
|
Math.round(event.localPosition[0] * 2) / 2,
|
||||||
Math.round(event.localPosition[1] * 2) / 2,
|
Math.round(event.localPosition[1] * 2) / 2,
|
||||||
@@ -223,16 +323,12 @@ export const ItemTool: React.FC = () => {
|
|||||||
Math.round(event.position[1] * 2) / 2,
|
Math.round(event.position[1] * 2) / 2,
|
||||||
Math.round(event.position[2] * 2) / 2,
|
Math.round(event.position[2] * 2) / 2,
|
||||||
)
|
)
|
||||||
|
cursorRef.current.rotation.y = rotation
|
||||||
|
|
||||||
const {
|
// Update draft item side and rotation
|
||||||
node: { start, end },
|
draftItem.current.side = side
|
||||||
} = event
|
draftItem.current.rotation = [0, rotation, 0]
|
||||||
const dx = end[0] - start[0]
|
|
||||||
const dz = end[1] - start[1]
|
|
||||||
const { normal } = event
|
|
||||||
const wallAngle = Math.atan2(dx, dz)
|
|
||||||
|
|
||||||
cursorRef.current.rotation.y = wallAngle + Math.PI / 2
|
|
||||||
const canPlace = checkCanPlace()
|
const canPlace = checkCanPlace()
|
||||||
if (draftItem.current && canPlace) {
|
if (draftItem.current && canPlace) {
|
||||||
draftItem.current.position = [
|
draftItem.current.position = [
|
||||||
@@ -243,8 +339,13 @@ export const ItemTool: React.FC = () => {
|
|||||||
const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id)
|
const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id)
|
||||||
if (draftItemMesh) {
|
if (draftItemMesh) {
|
||||||
draftItemMesh.position.copy(gridPosition.current)
|
draftItemMesh.position.copy(gridPosition.current)
|
||||||
|
draftItemMesh.rotation.y = rotation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useScene.getState().updateNode(draftItem.current.id, {
|
||||||
|
side,
|
||||||
|
rotation: [0, rotation, 0],
|
||||||
|
})
|
||||||
useScene.getState().dirtyNodes.add(event.node.id)
|
useScene.getState().dirtyNodes.add(event.node.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
1,
|
1,
|
||||||
1.1000000000000003
|
1.1000000000000003
|
||||||
],
|
],
|
||||||
"attachTo": "wall"
|
"attachTo": "wall-side"
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -335,7 +335,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
1,
|
1,
|
||||||
1.1000000000000003
|
1.1000000000000003
|
||||||
],
|
],
|
||||||
"attachTo": "wall"
|
"attachTo": "wall-side"
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -420,7 +420,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
0.5,
|
0.5,
|
||||||
0.5000000000000003
|
0.5000000000000003
|
||||||
],
|
],
|
||||||
"attachTo": "wall"
|
"attachTo": "wall-side"
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -757,7 +757,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
1,
|
1,
|
||||||
0.8999999999999999
|
0.8999999999999999
|
||||||
],
|
],
|
||||||
"attachTo": "wall"
|
"attachTo": "wall-side"
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1209,7 +1209,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
1,
|
1,
|
||||||
0.1
|
0.1
|
||||||
],
|
],
|
||||||
"attachTo": "wall"
|
"attachTo": "wall-side"
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1378,7 +1378,7 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
1,
|
1,
|
||||||
0.2
|
0.2
|
||||||
],
|
],
|
||||||
"attachTo": "wall"
|
"attachTo": "wall-side"
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export class SpatialGridManager {
|
|||||||
tEnd: t + halfW,
|
tEnd: t + halfW,
|
||||||
yStart: item.position[1],
|
yStart: item.position[1],
|
||||||
yEnd: item.position[1] + height,
|
yEnd: item.position[1] + height,
|
||||||
|
attachType: item.asset.attachTo as 'wall' | 'wall-side',
|
||||||
|
side: item.side,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,6 +103,8 @@ export class SpatialGridManager {
|
|||||||
tEnd: t + halfW,
|
tEnd: t + halfW,
|
||||||
yStart: item.position[1],
|
yStart: item.position[1],
|
||||||
yEnd: item.position[1] + height,
|
yEnd: item.position[1] + height,
|
||||||
|
attachType: item.asset.attachTo as 'wall' | 'wall-side',
|
||||||
|
side: item.side,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,6 +151,8 @@ export class SpatialGridManager {
|
|||||||
* @param localX - X position in wall-local space (distance from wall start)
|
* @param localX - X position in wall-local space (distance from wall start)
|
||||||
* @param localY - Y position (height from floor)
|
* @param localY - Y position (height from floor)
|
||||||
* @param dimensions - item dimensions [width, height, depth]
|
* @param dimensions - item dimensions [width, height, depth]
|
||||||
|
* @param attachType - 'wall' (needs both sides) or 'wall-side' (needs one side)
|
||||||
|
* @param side - which side for 'wall-side' items
|
||||||
* @param ignoreIds - item IDs to ignore in collision check
|
* @param ignoreIds - item IDs to ignore in collision check
|
||||||
*/
|
*/
|
||||||
canPlaceOnWall(
|
canPlaceOnWall(
|
||||||
@@ -155,6 +161,8 @@ export class SpatialGridManager {
|
|||||||
localX: number,
|
localX: number,
|
||||||
localY: number,
|
localY: number,
|
||||||
dimensions: [number, number, number],
|
dimensions: [number, number, number],
|
||||||
|
attachType: 'wall' | 'wall-side' = 'wall',
|
||||||
|
side?: 'front' | 'back',
|
||||||
ignoreIds?: string[],
|
ignoreIds?: string[],
|
||||||
) {
|
) {
|
||||||
const wallLength = this.getWallLength(wallId)
|
const wallLength = this.getWallLength(wallId)
|
||||||
@@ -173,6 +181,8 @@ export class SpatialGridManager {
|
|||||||
itemWidth,
|
itemWidth,
|
||||||
localY,
|
localY,
|
||||||
itemHeight,
|
itemHeight,
|
||||||
|
attachType,
|
||||||
|
side,
|
||||||
ignoreIds,
|
ignoreIds,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,14 +53,15 @@ export function initSpatialGridSync() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect updated nodes (items with position/rotation/parentId changes)
|
// Detect updated nodes (items with position/rotation/parentId/side changes)
|
||||||
for (const [id, node] of Object.entries(state.nodes)) {
|
for (const [id, node] of Object.entries(state.nodes)) {
|
||||||
const prev = prevState.nodes[id as AnyNode['id']]
|
const prev = prevState.nodes[id as AnyNode['id']]
|
||||||
if (prev && node.type === 'item' && prev.type === 'item') {
|
if (prev && node.type === 'item' && prev.type === 'item') {
|
||||||
if (
|
if (
|
||||||
!arraysEqual(node.position, prev.position) ||
|
!arraysEqual(node.position, prev.position) ||
|
||||||
!arraysEqual(node.rotation, prev.rotation) ||
|
!arraysEqual(node.rotation, prev.rotation) ||
|
||||||
node.parentId !== prev.parentId
|
node.parentId !== prev.parentId ||
|
||||||
|
node.side !== prev.side
|
||||||
) {
|
) {
|
||||||
const levelId = resolveLevelId(node, state.nodes)
|
const levelId = resolveLevelId(node, state.nodes)
|
||||||
spatialGridManager.handleNodeUpdated(node, levelId)
|
spatialGridManager.handleNodeUpdated(node, levelId)
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export function useSpatialQuery() {
|
|||||||
localX: number,
|
localX: number,
|
||||||
localY: number,
|
localY: number,
|
||||||
dimensions: [number, number, number],
|
dimensions: [number, number, number],
|
||||||
|
attachType: 'wall' | 'wall-side' = 'wall',
|
||||||
|
side?: 'front' | 'back',
|
||||||
ignoreIds?: string[],
|
ignoreIds?: string[],
|
||||||
) => {
|
) => {
|
||||||
return spatialGridManager.canPlaceOnWall(
|
return spatialGridManager.canPlaceOnWall(
|
||||||
@@ -31,6 +33,8 @@ export function useSpatialQuery() {
|
|||||||
localX,
|
localX,
|
||||||
localY,
|
localY,
|
||||||
dimensions,
|
dimensions,
|
||||||
|
attachType,
|
||||||
|
side,
|
||||||
ignoreIds,
|
ignoreIds,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
type WallSide = 'front' | 'back'
|
||||||
|
type AttachType = 'wall' | 'wall-side'
|
||||||
|
|
||||||
|
// Small tolerance for floating point comparison to allow adjacent items
|
||||||
|
const EPSILON = 0.001
|
||||||
|
|
||||||
interface WallItemPlacement {
|
interface WallItemPlacement {
|
||||||
itemId: string
|
itemId: string
|
||||||
wallId: string
|
wallId: string
|
||||||
@@ -5,12 +11,27 @@ interface WallItemPlacement {
|
|||||||
tEnd: number
|
tEnd: number
|
||||||
yStart: number // height range
|
yStart: number // height range
|
||||||
yEnd: number
|
yEnd: number
|
||||||
|
attachType?: AttachType // 'wall' blocks both sides, 'wall-side' blocks one side (undefined = 'wall' for legacy)
|
||||||
|
side?: WallSide // Which side for 'wall-side' items (undefined means both for 'wall')
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
* @param wallId - The wall to place on
|
||||||
|
* @param wallLength - Length of the wall
|
||||||
|
* @param wallHeight - Height of the wall
|
||||||
|
* @param tCenter - Parametric center position (0-1) along wall
|
||||||
|
* @param itemWidth - Width of the item
|
||||||
|
* @param yBottom - Bottom Y position of the item
|
||||||
|
* @param itemHeight - Height of the item
|
||||||
|
* @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
|
||||||
|
*/
|
||||||
canPlaceOnWall(
|
canPlaceOnWall(
|
||||||
wallId: string,
|
wallId: string,
|
||||||
wallLength: number,
|
wallLength: number,
|
||||||
@@ -19,6 +40,8 @@ export class WallSpatialGrid {
|
|||||||
itemWidth: number,
|
itemWidth: number,
|
||||||
yBottom: number,
|
yBottom: number,
|
||||||
itemHeight: number,
|
itemHeight: number,
|
||||||
|
attachType: AttachType = 'wall',
|
||||||
|
side?: WallSide,
|
||||||
ignoreIds: string[] = [],
|
ignoreIds: string[] = [],
|
||||||
): { valid: boolean; conflictIds: string[] } {
|
): { valid: boolean; conflictIds: string[] } {
|
||||||
const halfW = itemWidth / wallLength / 2
|
const halfW = itemWidth / wallLength / 2
|
||||||
@@ -40,17 +63,53 @@ export class WallSpatialGrid {
|
|||||||
for (const placement of existing) {
|
for (const placement of existing) {
|
||||||
if (ignoreSet.has(placement.itemId)) continue
|
if (ignoreSet.has(placement.itemId)) continue
|
||||||
|
|
||||||
const tOverlap = tStart < placement.tEnd && tEnd > placement.tStart
|
// Use EPSILON tolerance to allow items to be exactly adjacent
|
||||||
const yOverlap = yStart < placement.yEnd && yEnd > placement.yStart
|
const tOverlap = tStart < placement.tEnd - EPSILON && tEnd > placement.tStart + EPSILON
|
||||||
|
const yOverlap = yStart < placement.yEnd - EPSILON && yEnd > placement.yStart + EPSILON
|
||||||
|
|
||||||
if (tOverlap && yOverlap) {
|
if (tOverlap && yOverlap) {
|
||||||
conflicts.push(placement.itemId)
|
// Check side conflicts based on attach types
|
||||||
|
const hasConflict = this.checkSideConflict(attachType, side, placement)
|
||||||
|
if (hasConflict) {
|
||||||
|
conflicts.push(placement.itemId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { valid: conflicts.length === 0, conflictIds: conflicts }
|
return { valid: conflicts.length === 0, conflictIds: conflicts }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two items conflict based on their attach types and sides
|
||||||
|
* - 'wall' items block both sides, so they conflict with everything
|
||||||
|
* - 'wall-side' items only conflict if they're on the same side or if the other is a 'wall' item
|
||||||
|
*/
|
||||||
|
private checkSideConflict(
|
||||||
|
newAttachType: AttachType,
|
||||||
|
newSide: WallSide | undefined,
|
||||||
|
existing: WallItemPlacement,
|
||||||
|
): boolean {
|
||||||
|
// Treat undefined/legacy attachType as 'wall' (blocks both sides)
|
||||||
|
const existingAttachType = existing.attachType ?? 'wall'
|
||||||
|
|
||||||
|
// If new item is 'wall' type, it conflicts with everything (needs both sides)
|
||||||
|
if (newAttachType === 'wall') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If existing item is 'wall' type, it blocks both sides
|
||||||
|
if (existingAttachType === 'wall') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both are 'wall-side' - only conflict if they're on the same side
|
||||||
|
// If either side is undefined, be conservative and assume conflict
|
||||||
|
if (!newSide || !existing.side) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return newSide === existing.side
|
||||||
|
}
|
||||||
|
|
||||||
insert(placement: WallItemPlacement) {
|
insert(placement: WallItemPlacement) {
|
||||||
const { wallId, itemId } = placement
|
const { wallId, itemId } = placement
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user