@Ron-Alex this commit closes #82 now if a user attempts to place an item on an occupied space we now find the nearest unoccupied tile and place it there instead.

This commit is contained in:
Stan
2025-08-18 15:17:43 +01:00
parent 7500c5b027
commit f5ebad6b22
5 changed files with 196 additions and 23 deletions

View File

@@ -1,6 +1,6 @@
import React, { useCallback } from 'react';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { getTilePosition, CoordsUtils, generateId } from 'src/utils';
import { getTilePosition, CoordsUtils, generateId, findNearestUnoccupiedTile } from 'src/utils';
import { useScene } from 'src/hooks/useScene';
import { useModelStore } from 'src/stores/modelStore';
import { VIEW_ITEM_DEFAULTS } from 'src/config';
@@ -50,6 +50,9 @@ export const ContextMenuManager = ({ anchorEl }: Props) => {
if (model.icons.length > 0) {
const modelItemId = generateId();
const firstIcon = model.icons[0];
// Find nearest unoccupied tile (should return the same tile since context menu is for empty tiles)
const targetTile = findNearestUnoccupiedTile(contextMenu.tile, scene) || contextMenu.tile;
scene.placeIcon({
modelItem: {
@@ -60,7 +63,7 @@ export const ContextMenuManager = ({ anchorEl }: Props) => {
viewItem: {
...VIEW_ITEM_DEFAULTS,
id: modelItemId,
tile: contextMenu.tile
tile: targetTile
}
});
}

View File

@@ -6,7 +6,8 @@ import {
CoordsUtils,
hasMovedTile,
getAnchorParent,
getItemAtTile
getItemAtTile,
findNearestUnoccupiedTilesForGroup
} from 'src/utils';
const dragItems = (
@@ -15,14 +16,40 @@ const dragItems = (
delta: Coords,
scene: ReturnType<typeof useScene>
) => {
items.forEach((item) => {
if (item.type === 'ITEM') {
// Separate items from other draggable elements
const itemRefs = items.filter(item => item.type === 'ITEM');
const otherRefs = items.filter(item => item.type !== 'ITEM');
// If there are items being dragged, find nearest unoccupied tiles for them
if (itemRefs.length > 0) {
const itemsWithTargets = itemRefs.map(item => {
const node = getItemByIdOrThrow(scene.items, item.id).value;
scene.updateViewItem(item.id, {
tile: CoordsUtils.add(node.tile, delta)
return {
id: item.id,
targetTile: CoordsUtils.add(node.tile, delta)
};
});
// Find nearest unoccupied tiles for all items
const newTiles = findNearestUnoccupiedTilesForGroup(
itemsWithTargets,
scene,
itemRefs.map(item => item.id) // Exclude the items being dragged
);
// If we found valid positions for all items, move them
if (newTiles) {
itemRefs.forEach((item, index) => {
scene.updateViewItem(item.id, {
tile: newTiles[index]
});
});
} else if (item.type === 'RECTANGLE') {
}
}
// Handle non-item references (rectangles, textboxes, connector anchors)
otherRefs.forEach((item) => {
if (item.type === 'RECTANGLE') {
const rectangle = getItemByIdOrThrow(scene.rectangles, item.id).value;
const newFrom = CoordsUtils.add(rectangle.from, delta);
const newTo = CoordsUtils.add(rectangle.to, delta);

View File

@@ -1,6 +1,6 @@
import { produce } from 'immer';
import { ModeActions } from 'src/types';
import { generateId, getItemAtTile } from 'src/utils';
import { generateId, getItemAtTile, findNearestUnoccupiedTile } from 'src/utils';
import { VIEW_ITEM_DEFAULTS } from 'src/config';
export const PlaceIcon: ModeActions = {
@@ -27,20 +27,29 @@ export const PlaceIcon: ModeActions = {
if (uiState.mode.type !== 'PLACE_ICON') return;
if (uiState.mode.id !== null) {
const modelItemId = generateId();
// Find the nearest unoccupied tile to the target position
const targetTile = findNearestUnoccupiedTile(
uiState.mouse.position.tile,
scene
);
scene.placeIcon({
modelItem: {
id: modelItemId,
name: 'Untitled',
icon: uiState.mode.id
},
viewItem: {
...VIEW_ITEM_DEFAULTS,
id: modelItemId,
tile: uiState.mouse.position.tile
}
});
// Place the icon on the nearest unoccupied tile
if (targetTile) {
const modelItemId = generateId();
scene.placeIcon({
modelItem: {
id: modelItemId,
name: 'Untitled',
icon: uiState.mode.id
},
viewItem: {
...VIEW_ITEM_DEFAULTS,
id: modelItemId,
tile: targetTile
}
});
}
}
uiState.actions.setMode(

View File

@@ -0,0 +1,133 @@
import { Coords } from 'src/types';
import { useScene } from 'src/hooks/useScene';
import { getItemAtTile } from './renderer';
/**
* Finds the nearest unoccupied tile to the target tile using a spiral search pattern
* @param targetTile - The desired tile position
* @param scene - The current scene
* @param maxDistance - Maximum search distance (default: 10)
* @returns The nearest unoccupied tile, or null if none found within maxDistance
*/
export const findNearestUnoccupiedTile = (
targetTile: Coords,
scene: ReturnType<typeof useScene>,
maxDistance: number = 10
): Coords | null => {
// Check if the target tile itself is unoccupied
const itemAtTarget = getItemAtTile({ tile: targetTile, scene });
if (!itemAtTarget || itemAtTarget.type !== 'ITEM') {
return targetTile;
}
// Spiral search pattern: right, down, left, up
const directions = [
{ x: 1, y: 0 }, // right
{ x: 0, y: 1 }, // down
{ x: -1, y: 0 }, // left
{ x: 0, y: -1 } // up
];
// Search in expanding rings around the target
for (let distance = 1; distance <= maxDistance; distance++) {
// Start from the top-left of the ring
let currentTile = {
x: targetTile.x - distance,
y: targetTile.y - distance
};
// Check all tiles in this ring
for (let side = 0; side < 4; side++) {
const direction = directions[side];
const sideLength = distance * 2;
for (let step = 0; step < sideLength; step++) {
// Move to the next tile on this side of the ring
currentTile = {
x: currentTile.x + direction.x,
y: currentTile.y + direction.y
};
// Check if this tile is within bounds and unoccupied
const itemAtTile = getItemAtTile({ tile: currentTile, scene });
if (!itemAtTile || itemAtTile.type !== 'ITEM') {
return currentTile;
}
}
}
}
// No unoccupied tile found within maxDistance
return null;
};
/**
* Finds the nearest unoccupied tile for multiple items being placed/moved
* Ensures all items can be placed without overlapping
* @param items - Array of items with their target tiles
* @param scene - The current scene
* @param excludeIds - IDs of items to exclude from occupation check (e.g., items being moved)
* @returns Array of nearest unoccupied tiles for each item, or null if cannot place all
*/
export const findNearestUnoccupiedTilesForGroup = (
items: { id: string; targetTile: Coords }[],
scene: ReturnType<typeof useScene>,
excludeIds: string[] = []
): Coords[] | null => {
const result: Coords[] = [];
const occupiedTiles = new Set<string>();
// Add existing items to occupied tiles (excluding the ones being moved)
scene.items.forEach(item => {
if (!excludeIds.includes(item.id)) {
occupiedTiles.add(`${item.tile.x},${item.tile.y}`);
}
});
// Find unoccupied tiles for each item
for (const item of items) {
let foundTile: Coords | null = null;
const targetKey = `${item.targetTile.x},${item.targetTile.y}`;
// Check if target is available
if (!occupiedTiles.has(targetKey)) {
foundTile = item.targetTile;
} else {
// Search for nearest unoccupied tile
for (let distance = 1; distance <= 10; distance++) {
// Check tiles in a square ring at this distance
for (let dx = -distance; dx <= distance; dx++) {
for (let dy = -distance; dy <= distance; dy++) {
// Only check tiles on the ring perimeter
if (Math.abs(dx) === distance || Math.abs(dy) === distance) {
const checkTile = {
x: item.targetTile.x + dx,
y: item.targetTile.y + dy
};
const checkKey = `${checkTile.x},${checkTile.y}`;
if (!occupiedTiles.has(checkKey)) {
const itemAtTile = getItemAtTile({ tile: checkTile, scene });
if (!itemAtTile || itemAtTile.type !== 'ITEM' || excludeIds.includes(itemAtTile.id)) {
foundTile = checkTile;
break;
}
}
}
}
if (foundTile) break;
}
if (foundTile) break;
}
}
if (!foundTile) {
return null; // Cannot place all items
}
result.push(foundTile);
occupiedTiles.add(`${foundTile.x},${foundTile.y}`);
}
return result;
};

View File

@@ -5,3 +5,4 @@ export * from './pathfinder';
export * from './renderer';
export * from './exportOptions';
export * from './model';
export * from './findNearestUnoccupiedTile';