draft spatial grid

This commit is contained in:
wass08
2026-01-18 10:54:02 +09:00
parent 4994384db5
commit 8bf933ed36
13 changed files with 604 additions and 19 deletions
@@ -0,0 +1,194 @@
import { AnyNode, ItemNode, WallNode } from "../../schema";
import { SpatialGrid } from "./spatial-grid";
import { WallSpatialGrid } from "./wall-spatial-grid";
export class SpatialGridManager {
private floorGrids = new Map<string, SpatialGrid>(); // levelId -> grid
private wallGrids = new Map<string, WallSpatialGrid>(); // levelId -> wall grid
private walls = new Map<string, WallNode>(); // wallId -> wall data (for length calculations)
constructor(private cellSize = 0.5) {}
private getFloorGrid(levelId: string): SpatialGrid {
if (!this.floorGrids.has(levelId)) {
this.floorGrids.set(
levelId,
new SpatialGrid({ cellSize: this.cellSize }),
);
}
return this.floorGrids.get(levelId)!;
}
private getWallGrid(levelId: string): WallSpatialGrid {
if (!this.wallGrids.has(levelId)) {
this.wallGrids.set(levelId, new WallSpatialGrid());
}
return this.wallGrids.get(levelId)!;
}
private getWallLength(wallId: string): number {
const wall = this.walls.get(wallId);
if (!wall) return 0;
const dx = wall.end[0] - wall.start[0];
const dy = wall.end[1] - wall.start[1];
return Math.sqrt(dx * dx + dy * dy);
}
// Called when nodes change
handleNodeCreated(node: AnyNode, levelId: string) {
if (node.type === "wall") {
const wall = node as WallNode;
this.walls.set(wall.id, wall);
} else if (node.type === "item") {
const item = node as ItemNode;
if (
item.asset.attachTo === "wall" ||
item.asset.attachTo === "wall-side"
) {
// Wall-attached item
if (item.wallId && item.wallT !== undefined) {
const wallLength = this.getWallLength(item.wallId);
if (wallLength > 0) {
const [width, height] = item.asset.dimensions;
const halfW = width / wallLength / 2;
const halfH = height / 2;
this.getWallGrid(levelId).insert({
itemId: item.id,
wallId: item.wallId,
tStart: item.wallT - halfW,
tEnd: item.wallT + halfW,
yStart: item.position[1] - halfH,
yEnd: item.position[1] + halfH,
});
}
}
} else if (!item.asset.attachTo) {
// Floor item
this.getFloorGrid(levelId).insert(
item.id,
item.position,
item.asset.dimensions,
item.rotation,
);
console.log(
"inserting floor item",
item.id,
item.position,
item.asset.dimensions,
);
}
}
}
handleNodeUpdated(node: AnyNode, levelId: string) {
if (node.type === "wall") {
const wall = node as WallNode;
this.walls.set(wall.id, wall);
} else if (node.type === "item") {
const item = node as ItemNode;
if (
item.asset.attachTo === "wall" ||
item.asset.attachTo === "wall-side"
) {
// Remove old placement and re-insert
this.getWallGrid(levelId).removeByItemId(item.id);
if (item.wallId && item.wallT !== undefined) {
const wallLength = this.getWallLength(item.wallId);
if (wallLength > 0) {
const [width, height] = item.asset.dimensions;
const halfW = width / wallLength / 2;
const halfH = height / 2;
this.getWallGrid(levelId).insert({
itemId: item.id,
wallId: item.wallId,
tStart: item.wallT - halfW,
tEnd: item.wallT + halfW,
yStart: item.position[1] - halfH,
yEnd: item.position[1] + halfH,
});
}
}
} else if (!item.asset.attachTo) {
this.getFloorGrid(levelId).update(
item.id,
item.position,
item.asset.dimensions,
item.rotation,
);
}
}
}
handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) {
if (nodeType === "wall") {
this.walls.delete(nodeId);
// Remove all items attached to this wall from the spatial grid
const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId);
return removedItemIds; // Caller can use this to delete the items from scene
} else if (nodeType === "item") {
this.getFloorGrid(levelId).remove(nodeId);
this.getWallGrid(levelId).removeByItemId(nodeId);
}
return [];
}
// Query methods
canPlaceOnFloor(
levelId: string,
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
ignoreIds?: string[],
) {
const grid = this.getFloorGrid(levelId);
console.log("canPlaceOnFloor - grid item count:", grid.getItemCount());
return grid.canPlace(
position,
dimensions,
rotation,
ignoreIds,
);
}
canPlaceOnWall(
levelId: string,
wallId: string,
tCenter: number,
itemWidth: number,
yCenter: number,
itemHeight: number,
ignoreIds?: string[],
) {
const wallLength = this.getWallLength(wallId);
if (wallLength === 0) {
return { valid: false, conflictIds: [] };
}
return this.getWallGrid(levelId).canPlaceOnWall(
wallId,
wallLength,
tCenter,
itemWidth,
yCenter,
itemHeight,
ignoreIds,
);
}
getWallForItem(levelId: string, itemId: string): string | undefined {
return this.getWallGrid(levelId).getWallForItem(itemId);
}
clearLevel(levelId: string) {
this.floorGrids.delete(levelId);
this.wallGrids.delete(levelId);
}
clear() {
this.floorGrids.clear();
this.wallGrids.clear();
this.walls.clear();
}
}
// Singleton instance
export const spatialGridManager = new SpatialGridManager();
@@ -0,0 +1,62 @@
import { AnyNode } from "../../schema";
import useScene from "../../store/use-scene";
import { spatialGridManager } from "./spatial-grid-manager";
function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): string {
// If the node itself is a level
if (node.type === "level") return node.id;
// Walk up parent chain to find level
// This assumes you track parentId or can derive it
let current: AnyNode | undefined = node;
while (current && current.parentId) {
if (current.type === "level") return current.id;
// Find parent (you might need to add parentId to your schema or derive it)
current = nodes[current.parentId];
}
return "default"; // fallback for orphaned items
}
// Call this once at app initialization
export function initSpatialGridSync() {
const store = useScene;
// Subscribe to all changes
store.subscribe((state, prevState) => {
// Detect added nodes
for (const [id, node] of Object.entries(state.nodes)) {
if (!prevState.nodes[id as AnyNode["id"]]) {
const levelId = resolveLevelId(node, state.nodes);
spatialGridManager.handleNodeCreated(node, levelId);
}
}
// Detect removed nodes
for (const [id, node] of Object.entries(prevState.nodes)) {
if (!state.nodes[id as AnyNode["id"]]) {
const levelId = resolveLevelId(node, prevState.nodes);
spatialGridManager.handleNodeDeleted(id, node.type, levelId);
}
}
// Detect updated nodes (only items with position/rotation 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)
) {
const levelId = resolveLevelId(node, state.nodes);
spatialGridManager.handleNodeUpdated(node, levelId);
}
}
}
});
}
function arraysEqual(a: number[], b: number[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
@@ -0,0 +1,161 @@
type CellKey = `${number},${number}`;
interface GridCell {
itemIds: Set<string>;
}
interface SpatialGridConfig {
cellSize: number; // e.g., 0.5 meters = Sims-style half-tile
}
export class SpatialGrid {
private cells = new Map<CellKey, GridCell>();
private itemCells = new Map<string, Set<CellKey>>(); // reverse lookup
constructor(private config: SpatialGridConfig) {}
private posToCell(x: number, z: number): [number, number] {
return [
Math.floor(x / this.config.cellSize),
Math.floor(z / this.config.cellSize),
];
}
private cellKey(cx: number, cz: number): CellKey {
return `${cx},${cz}`;
}
// Get all cells an item occupies based on its AABB
private getItemCells(
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
): CellKey[] {
// Simplified: axis-aligned bounding box
// For full rotation support, compute rotated corners
const [x, , z] = position;
const [w, , d] = dimensions;
const yRot = rotation[1]; // Y-axis rotation
// Compute rotated footprint (simplified for 90° increments)
const cos = Math.abs(Math.cos(yRot));
const sin = Math.abs(Math.sin(yRot));
const rotatedW = w * cos + d * sin;
const rotatedD = w * sin + d * cos;
const minX = x - rotatedW / 2;
const maxX = x + rotatedW / 2;
const minZ = z - rotatedD / 2;
const maxZ = z + rotatedD / 2;
const [minCx, minCz] = this.posToCell(minX, minZ);
const [maxCx, maxCz] = this.posToCell(maxX, maxZ);
const keys: CellKey[] = [];
for (let cx = minCx; cx <= maxCx; cx++) {
for (let cz = minCz; cz <= maxCz; cz++) {
keys.push(this.cellKey(cx, cz));
}
}
return keys;
}
// Register an item
insert(
itemId: string,
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
) {
const cellKeys = this.getItemCells(position, dimensions, rotation);
this.itemCells.set(itemId, new Set(cellKeys));
for (const key of cellKeys) {
if (!this.cells.has(key)) {
this.cells.set(key, { itemIds: new Set() });
}
this.cells.get(key)!.itemIds.add(itemId);
}
}
// Remove an item
remove(itemId: string) {
const cellKeys = this.itemCells.get(itemId);
if (!cellKeys) return;
for (const key of cellKeys) {
const cell = this.cells.get(key);
if (cell) {
cell.itemIds.delete(itemId);
if (cell.itemIds.size === 0) {
this.cells.delete(key);
}
}
}
this.itemCells.delete(itemId);
}
// Update = remove + insert
update(
itemId: string,
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
) {
this.remove(itemId);
this.insert(itemId, position, dimensions, rotation);
}
// Query: is this placement valid?
canPlace(
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
ignoreIds: string[] = [],
): { valid: boolean; conflictIds: string[] } {
const cellKeys = this.getItemCells(position, dimensions, rotation);
const ignoreSet = new Set(ignoreIds);
const conflicts = new Set<string>();
console.log("checking cells", cellKeys);
for (const key of cellKeys) {
const cell = this.cells.get(key);
if (cell) {
for (const id of cell.itemIds) {
if (!ignoreSet.has(id)) {
conflicts.add(id);
}
}
}
}
return {
valid: conflicts.size === 0,
conflictIds: [...conflicts],
};
}
// Query: get all items near a point (for snapping, selection, etc.)
queryRadius(x: number, z: number, radius: number): string[] {
const cellRadius = Math.ceil(radius / this.config.cellSize);
const [cx, cz] = this.posToCell(x, z);
const found = new Set<string>();
for (let dx = -cellRadius; dx <= cellRadius; dx++) {
for (let dz = -cellRadius; dz <= cellRadius; dz++) {
const cell = this.cells.get(this.cellKey(cx + dx, cz + dz));
if (cell) {
for (const id of cell.itemIds) {
found.add(id);
}
}
}
}
return [...found];
}
getItemCount(): number {
return this.itemCells.size;
}
}
@@ -0,0 +1,26 @@
import { useCallback } from "react";
import { spatialGridManager } from "./spatial-grid-manager";
import { LevelNode } from "../../schema";
export function useSpatialQuery() {
const canPlace = useCallback(
(
levelId: LevelNode["id"],
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
ignoreIds?: string[],
) => {
return spatialGridManager.canPlaceOnFloor(
levelId,
position,
dimensions,
rotation,
ignoreIds,
);
},
[],
);
return { canPlace };
}
@@ -0,0 +1,97 @@
interface WallItemPlacement {
itemId: string;
wallId: string;
tStart: number; // 0-1 parametric position along wall
tEnd: number;
yStart: number; // height range
yEnd: number;
}
export class WallSpatialGrid {
private wallItems = new Map<string, WallItemPlacement[]>(); // wallId -> placements
private itemToWall = new Map<string, string>(); // itemId -> wallId (reverse lookup)
canPlaceOnWall(
wallId: string,
wallLength: number,
tCenter: number,
itemWidth: number,
yCenter: number,
itemHeight: number,
ignoreIds: string[] = [],
): { valid: boolean; conflictIds: string[] } {
const halfW = itemWidth / wallLength / 2;
const halfH = itemHeight / 2;
const tStart = tCenter - halfW;
const tEnd = tCenter + halfW;
const yStart = yCenter - halfH;
const yEnd = yCenter + halfH;
const existing = this.wallItems.get(wallId) ?? [];
const ignoreSet = new Set(ignoreIds);
const conflicts: string[] = [];
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;
if (tOverlap && yOverlap) {
conflicts.push(placement.itemId);
}
}
return { valid: conflicts.length === 0, conflictIds: conflicts };
}
insert(placement: WallItemPlacement) {
const { wallId, itemId } = placement;
if (!this.wallItems.has(wallId)) {
this.wallItems.set(wallId, []);
}
this.wallItems.get(wallId)!.push(placement);
this.itemToWall.set(itemId, wallId);
}
remove(wallId: string, itemId: string) {
const items = this.wallItems.get(wallId);
if (items) {
const idx = items.findIndex((p) => p.itemId === itemId);
if (idx !== -1) items.splice(idx, 1);
}
this.itemToWall.delete(itemId);
}
// The missing method!
removeByItemId(itemId: string) {
const wallId = this.itemToWall.get(itemId);
if (wallId) {
this.remove(wallId, itemId);
}
}
// Useful for when a wall is deleted - remove all items on it
removeWall(wallId: string): string[] {
const items = this.wallItems.get(wallId) ?? [];
const removedIds = items.map((p) => p.itemId);
for (const itemId of removedIds) {
this.itemToWall.delete(itemId);
}
this.wallItems.delete(wallId);
return removedIds; // Return removed item IDs in case you need to delete them from scene
}
// Get which wall an item is on
getWallForItem(itemId: string): string | undefined {
return this.itemToWall.get(itemId);
}
clear() {
this.wallItems.clear();
this.itemToWall.clear();
}
}
+10 -2
View File
@@ -7,13 +7,21 @@ export {
useRegistry,
} from "./hooks/scene-registry/scene-registry";
export { useSpatialQuery } from "./hooks/spatial-grid/use-spatial-query";
export { initSpatialGridSync } from "./hooks/spatial-grid/spatial-grid-sync";
// Systems
export { LevelSystem } from "../../viewer/src/systems/level/level-system";
export { WallSystem } from "./systems/wall/wall-system";
// Events
export { emitter, eventSuffixes } from "./events/bus";
export type { ItemEvent, WallEvent, NodeEvent, GridEvent, EventSuffix } from "./events/bus";
export type {
ItemEvent,
WallEvent,
NodeEvent,
GridEvent,
EventSuffix,
} from "./events/bus";
// Schema
export * from "./schema";
+5
View File
@@ -16,6 +16,7 @@ const assetSchema = z.object({
});
export type AssetInput = z.input<typeof assetSchema>;
export type Asset = z.infer<typeof assetSchema>;
export const ItemNode = BaseNode.extend({
id: objectId("item"),
@@ -24,6 +25,10 @@ export const ItemNode = BaseNode.extend({
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
side: z.enum(["front", "back"]).optional(),
// Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side")
wallId: z.string().optional(),
wallT: z.number().optional(), // 0-1 parametric position along wall
asset: assetSchema,
}).describe(dedent`Item node - used to represent a item in the building
- position: position in level coordinate system (or parent coordinate system if attached)
@@ -72,7 +72,7 @@ export function generateExtrudedWall(
const end = new THREE.Vector2(wallNode.end[0], wallNode.end[1]);
const length = start.distanceTo(end);
const height = wallNode.height || 2.5;
const thickness = wallNode.thickness || 0.2;
const thickness = wallNode.thickness || 0.1;
// 2. Create the Main Wall Shape (a rectangle in 2D)
// We draw this on the XY plane, where X is "along the wall" and Y is "height"