diff --git a/apps/editor/components/tools/item/item-tool.tsx b/apps/editor/components/tools/item/item-tool.tsx index b824a0ed..c8eb93af 100644 --- a/apps/editor/components/tools/item/item-tool.tsx +++ b/apps/editor/components/tools/item/item-tool.tsx @@ -36,6 +36,71 @@ const stripTransient = (meta: any) => { 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 = () => { const cursorRef = useRef(null!) const draftItem = useRef(null) @@ -65,6 +130,8 @@ export const ItemTool: React.FC = () => { gridPosition.current.x, gridPosition.current.y, draftItem.current.asset.dimensions, + draftItem.current.asset.attachTo as 'wall' | 'wall-side', + draftItem.current.side, [draftItem.current.id], ) placeable = result.valid @@ -156,19 +223,33 @@ export const ItemTool: React.FC = () => { draftItem.current?.asset.attachTo === 'wall' || 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() isOnWall.current = true currentWallId = event.node.id + + // Determine side and rotation from normal + const side = getSideFromNormal(event.normal) + const rotation = calculateRotationFromNormal(event.normal) + gridPosition.current.set( Math.round(event.localPosition[0] * 2) / 2, Math.round(event.localPosition[1] * 2) / 2, Math.round(event.localPosition[2] * 2) / 2, ) draftItem.current.parentId = event.node.id + draftItem.current.side = side + draftItem.current.rotation = [0, rotation, 0] + useScene.getState().updateNode(draftItem.current.id, { position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], parentId: event.node.id, + side, + rotation: [0, rotation, 0], }) + cursorRef.current.rotation.y = rotation checkCanPlace() } } @@ -189,16 +270,26 @@ export const ItemTool: React.FC = () => { } const onWallClick = (event: WallEvent) => { - event.stopPropagation() 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 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.getState().updateNode(draftItem.current.id, { position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], parentId: event.node.id, + side, + rotation, metadata: stripTransient(draftItem.current.metadata), }) useScene.getState().dirtyNodes.add(event.node.id) @@ -211,8 +302,17 @@ export const ItemTool: React.FC = () => { const onWallMove = (event: WallEvent) => { if (isOnWall.current === false) return - event.stopPropagation() 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( Math.round(event.localPosition[0] * 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[2] * 2) / 2, ) + cursorRef.current.rotation.y = rotation - const { - node: { start, end }, - } = event - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const { normal } = event - const wallAngle = Math.atan2(dx, dz) + // Update draft item side and rotation + draftItem.current.side = side + draftItem.current.rotation = [0, rotation, 0] - cursorRef.current.rotation.y = wallAngle + Math.PI / 2 const canPlace = checkCanPlace() if (draftItem.current && canPlace) { draftItem.current.position = [ @@ -243,8 +339,13 @@ export const ItemTool: React.FC = () => { const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id) if (draftItemMesh) { 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) } } diff --git a/apps/editor/components/ui/item-catalog/catalog-items.tsx b/apps/editor/components/ui/item-catalog/catalog-items.tsx index 032d7df7..fe4049bd 100644 --- a/apps/editor/components/ui/item-catalog/catalog-items.tsx +++ b/apps/editor/components/ui/item-catalog/catalog-items.tsx @@ -306,7 +306,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ 1, 1.1000000000000003 ], - "attachTo": "wall" + "attachTo": "wall-side" }, { @@ -335,7 +335,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ 1, 1.1000000000000003 ], - "attachTo": "wall" + "attachTo": "wall-side" }, { @@ -420,7 +420,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ 0.5, 0.5000000000000003 ], - "attachTo": "wall" + "attachTo": "wall-side" }, { @@ -757,7 +757,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ 1, 0.8999999999999999 ], - "attachTo": "wall" + "attachTo": "wall-side" }, { @@ -1209,7 +1209,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ 1, 0.1 ], - "attachTo": "wall" + "attachTo": "wall-side" }, { @@ -1378,7 +1378,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ 1, 0.2 ], - "attachTo": "wall" + "attachTo": "wall-side" }, { diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 524f2607..a458bb8b 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -61,6 +61,8 @@ export class SpatialGridManager { tEnd: t + halfW, yStart: item.position[1], 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, yStart: item.position[1], 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 localY - Y position (height from floor) * @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 */ canPlaceOnWall( @@ -155,6 +161,8 @@ export class SpatialGridManager { localX: number, localY: number, dimensions: [number, number, number], + attachType: 'wall' | 'wall-side' = 'wall', + side?: 'front' | 'back', ignoreIds?: string[], ) { const wallLength = this.getWallLength(wallId) @@ -173,6 +181,8 @@ export class SpatialGridManager { itemWidth, localY, itemHeight, + attachType, + side, ignoreIds, ) } diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index 37d083d9..b40759ed 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -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)) { const prev = prevState.nodes[id as AnyNode['id']] if (prev && node.type === 'item' && prev.type === 'item') { if ( !arraysEqual(node.position, prev.position) || !arraysEqual(node.rotation, prev.rotation) || - node.parentId !== prev.parentId + node.parentId !== prev.parentId || + node.side !== prev.side ) { const levelId = resolveLevelId(node, state.nodes) spatialGridManager.handleNodeUpdated(node, levelId) diff --git a/packages/core/src/hooks/spatial-grid/use-spatial-query.ts b/packages/core/src/hooks/spatial-grid/use-spatial-query.ts index 2d16d9b7..4b1dc0d6 100644 --- a/packages/core/src/hooks/spatial-grid/use-spatial-query.ts +++ b/packages/core/src/hooks/spatial-grid/use-spatial-query.ts @@ -23,6 +23,8 @@ export function useSpatialQuery() { localX: number, localY: number, dimensions: [number, number, number], + attachType: 'wall' | 'wall-side' = 'wall', + side?: 'front' | 'back', ignoreIds?: string[], ) => { return spatialGridManager.canPlaceOnWall( @@ -31,6 +33,8 @@ export function useSpatialQuery() { localX, localY, dimensions, + attachType, + side, ignoreIds, ) }, diff --git a/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts b/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts index deb60c6d..d36a3bb2 100644 --- a/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts +++ b/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts @@ -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 { itemId: string wallId: string @@ -5,12 +11,27 @@ interface WallItemPlacement { tEnd: number yStart: number // height range 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 { private wallItems = new Map() // wallId -> placements private itemToWall = new Map() // 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( wallId: string, wallLength: number, @@ -19,6 +40,8 @@ export class WallSpatialGrid { itemWidth: number, yBottom: number, itemHeight: number, + attachType: AttachType = 'wall', + side?: WallSide, ignoreIds: string[] = [], ): { valid: boolean; conflictIds: string[] } { const halfW = itemWidth / wallLength / 2 @@ -40,17 +63,53 @@ export class WallSpatialGrid { for (const placement of existing) { if (ignoreSet.has(placement.itemId)) continue - const tOverlap = tStart < placement.tEnd && tEnd > placement.tStart - const yOverlap = yStart < placement.yEnd && yEnd > placement.yStart + // Use EPSILON tolerance to allow items to be exactly adjacent + const tOverlap = tStart < placement.tEnd - EPSILON && tEnd > placement.tStart + EPSILON + const yOverlap = yStart < placement.yEnd - EPSILON && yEnd > placement.yStart + EPSILON 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 } } + /** + * 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) { const { wallId, itemId } = placement