draft spatial grid
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
emitter,
|
emitter,
|
||||||
|
initSpatialGridSync,
|
||||||
ItemNode,
|
ItemNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -32,6 +33,8 @@ import { ToolManager } from "./tools/tool-manager";
|
|||||||
|
|
||||||
const selectedObjects: Object3D[] = [];
|
const selectedObjects: Object3D[] = [];
|
||||||
|
|
||||||
|
initSpatialGridSync();
|
||||||
|
|
||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full bg-pink-50">
|
<div className="w-full h-full bg-pink-50">
|
||||||
|
|||||||
@@ -6,19 +6,21 @@ import {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useRegistry,
|
useRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
|
useSpatialQuery,
|
||||||
WallNode,
|
WallNode,
|
||||||
} from "@pascal-app/core";
|
} from "@pascal-app/core";
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
import { use, useEffect, useRef } from "react";
|
import { use, useEffect, useRef } from "react";
|
||||||
import { Line, Mesh, Vector3 } from "three";
|
import { BoxGeometry, Line, Mesh, Vector3 } from "three";
|
||||||
import { randInt } from "three/src/math/MathUtils.js";
|
import { randInt } from "three/src/math/MathUtils.js";
|
||||||
|
|
||||||
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);
|
||||||
const gridPosition = useRef(new Vector3(0, 0, 0));
|
const gridPosition = useRef(new Vector3(0, 0, 0));
|
||||||
const selectedItem = useEditor((state) => state.selectedItem);
|
const selectedItem = useEditor((state) => state.selectedItem);
|
||||||
|
const { canPlace } = useSpatialQuery();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedItem) {
|
if (!selectedItem) {
|
||||||
@@ -53,7 +55,7 @@ export const ItemTool: React.FC = () => {
|
|||||||
);
|
);
|
||||||
cursorRef.current.position.set(
|
cursorRef.current.position.set(
|
||||||
gridPosition.current.x,
|
gridPosition.current.x,
|
||||||
0.1,
|
0,
|
||||||
gridPosition.current.z,
|
gridPosition.current.z,
|
||||||
);
|
);
|
||||||
if (draftItem.current) {
|
if (draftItem.current) {
|
||||||
@@ -62,15 +64,29 @@ export const ItemTool: React.FC = () => {
|
|||||||
0,
|
0,
|
||||||
gridPosition.current.z,
|
gridPosition.current.z,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const currentLevelId = useViewer.getState().currentLevelId;
|
||||||
|
if (currentLevelId) {
|
||||||
|
const placeable = canPlace(
|
||||||
|
currentLevelId,
|
||||||
|
[gridPosition.current.x, 0, gridPosition.current.z],
|
||||||
|
selectedItem.dimensions,
|
||||||
|
[0, 0, 0],
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"placeable",
|
||||||
|
placeable,
|
||||||
|
[gridPosition.current.x, 0, gridPosition.current.z],
|
||||||
|
selectedItem.dimensions,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
const { currentLevelId } = useViewer.getState();
|
const { currentLevelId } = useViewer.getState();
|
||||||
|
|
||||||
console.log("oh", currentLevelId, draftItem.current);
|
|
||||||
if (!currentLevelId || !draftItem.current) return;
|
if (!currentLevelId || !draftItem.current) return;
|
||||||
|
|
||||||
console.log("oh");
|
|
||||||
useScene.temporal.getState().resume();
|
useScene.temporal.getState().resume();
|
||||||
useScene.getState().updateNode(draftItem.current.id, {
|
useScene.getState().updateNode(draftItem.current.id, {
|
||||||
position: [
|
position: [
|
||||||
@@ -88,6 +104,17 @@ export const ItemTool: React.FC = () => {
|
|||||||
emitter.on("grid:move", onGridMove);
|
emitter.on("grid:move", onGridMove);
|
||||||
emitter.on("grid:click", onGridClick);
|
emitter.on("grid:click", onGridClick);
|
||||||
|
|
||||||
|
const setupBoundingBox = () => {
|
||||||
|
const boxGeometry = new BoxGeometry(
|
||||||
|
selectedItem.dimensions[0],
|
||||||
|
selectedItem.dimensions[1],
|
||||||
|
selectedItem.dimensions[2],
|
||||||
|
);
|
||||||
|
boxGeometry.translate(0, selectedItem.dimensions[1] / 2, 0);
|
||||||
|
cursorRef.current.geometry = boxGeometry;
|
||||||
|
};
|
||||||
|
setupBoundingBox();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (draftItem.current) {
|
if (draftItem.current) {
|
||||||
useScene.getState().deleteNode(draftItem.current.id);
|
useScene.getState().deleteNode(draftItem.current.id);
|
||||||
@@ -109,8 +136,8 @@ export const ItemTool: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<mesh ref={cursorRef}>
|
<mesh ref={cursorRef}>
|
||||||
<boxGeometry args={[0.2, 0.2, 0.2]} />
|
<boxGeometry args={[0.1, 0.1, 0.1]} />
|
||||||
<meshStandardMaterial color="red" />
|
<meshStandardMaterial color="red" wireframe />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
name: "Couch",
|
name: "Couch",
|
||||||
thumbnail: "/items/couch-medium/thumbnail.webp",
|
thumbnail: "/items/couch-medium/thumbnail.webp",
|
||||||
src: "/items/couch-medium/model.glb",
|
src: "/items/couch-medium/model.glb",
|
||||||
scale: [0.4, 0.4, 0.4],
|
scale: [0.35, 0.35, 0.35],
|
||||||
dimensions: [4, 2, 2],
|
dimensions: [2, 0.8, 1],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "furniture",
|
category: "furniture",
|
||||||
name: "Small Couch",
|
name: "Small Couch",
|
||||||
thumbnail: "/items/couch-small/thumbnail.webp",
|
thumbnail: "/items/couch-small/thumbnail.webp",
|
||||||
src: "/items/couch-small/model.glb",
|
src: "/items/couch-small/model.glb",
|
||||||
scale: [0.4, 0.4, 0.4],
|
scale: [0.35, 0.35, 0.35],
|
||||||
dimensions: [3, 2, 2],
|
dimensions: [1, 0.8, 1],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "furniture",
|
category: "furniture",
|
||||||
@@ -154,10 +154,10 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
name: "Wall Art",
|
name: "Wall Art",
|
||||||
thumbnail: "/items/wall-art-06/thumbnail.webp",
|
thumbnail: "/items/wall-art-06/thumbnail.webp",
|
||||||
src: "/items/wall-art-06/model.glb",
|
src: "/items/wall-art-06/model.glb",
|
||||||
offset: [0, 1, 0.15],
|
offset: [0, 0.5, 0],
|
||||||
scale: [1, 1, 1],
|
scale: [1, 1, 1],
|
||||||
rotation: [0, Math.PI, 0],
|
rotation: [0, Math.PI, 0],
|
||||||
dimensions: [2, 2, 1],
|
dimensions: [1, 1, 0.1],
|
||||||
attachTo: "wall-side",
|
attachTo: "wall-side",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "@pascal-app/core";
|
} from "@pascal-app/core";
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { Asset } from "../../../packages/core/src/schema/nodes/item";
|
||||||
|
|
||||||
export type Phase = "site" | "structure" | "furnish";
|
export type Phase = "site" | "structure" | "furnish";
|
||||||
|
|
||||||
@@ -54,8 +55,8 @@ type EditorState = {
|
|||||||
setTool: (tool: Tool | null) => void;
|
setTool: (tool: Tool | null) => void;
|
||||||
catalogCategory: CatalogCategory | null;
|
catalogCategory: CatalogCategory | null;
|
||||||
setCatalogCategory: (category: CatalogCategory | null) => void;
|
setCatalogCategory: (category: CatalogCategory | null) => void;
|
||||||
selectedItem: AssetInput | null;
|
selectedItem: Asset | null;
|
||||||
setSelectedItem: (item: AssetInput) => void;
|
setSelectedItem: (item: Asset) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useEditor = create<EditorState>()((set, get) => ({
|
const useEditor = create<EditorState>()((set, get) => ({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,13 +7,21 @@ export {
|
|||||||
useRegistry,
|
useRegistry,
|
||||||
} from "./hooks/scene-registry/scene-registry";
|
} 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
|
// Systems
|
||||||
export { LevelSystem } from "../../viewer/src/systems/level/level-system";
|
|
||||||
export { WallSystem } from "./systems/wall/wall-system";
|
export { WallSystem } from "./systems/wall/wall-system";
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
export { emitter, eventSuffixes } from "./events/bus";
|
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
|
// Schema
|
||||||
export * from "./schema";
|
export * from "./schema";
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const assetSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type AssetInput = z.input<typeof assetSchema>;
|
export type AssetInput = z.input<typeof assetSchema>;
|
||||||
|
export type Asset = z.infer<typeof assetSchema>;
|
||||||
|
|
||||||
export const ItemNode = BaseNode.extend({
|
export const ItemNode = BaseNode.extend({
|
||||||
id: objectId("item"),
|
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]),
|
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
side: z.enum(["front", "back"]).optional(),
|
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,
|
asset: assetSchema,
|
||||||
}).describe(dedent`Item node - used to represent a item in the building
|
}).describe(dedent`Item node - used to represent a item in the building
|
||||||
- position: position in level coordinate system (or parent coordinate system if attached)
|
- 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 end = new THREE.Vector2(wallNode.end[0], wallNode.end[1]);
|
||||||
const length = start.distanceTo(end);
|
const length = start.distanceTo(end);
|
||||||
const height = wallNode.height || 2.5;
|
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)
|
// 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"
|
// We draw this on the XY plane, where X is "along the wall" and Y is "height"
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
import { Bvh, Environment, OrbitControls } from "@react-three/drei";
|
import { Bvh, Environment, OrbitControls } from "@react-three/drei";
|
||||||
import { Canvas, ThreeToJSXElements } from "@react-three/fiber";
|
import { Canvas, ThreeToJSXElements } from "@react-three/fiber";
|
||||||
|
|
||||||
import { LevelSystem, WallSystem } from "@pascal-app/core";
|
import { WallSystem } from "@pascal-app/core";
|
||||||
import { extend } from "@react-three/fiber";
|
import { extend } from "@react-three/fiber";
|
||||||
import * as THREE from "three/webgpu";
|
import * as THREE from "three/webgpu";
|
||||||
import { SceneRenderer } from "../renderers/scene-renderer";
|
import { SceneRenderer } from "../renderers/scene-renderer";
|
||||||
|
import { LevelSystem } from "../../systems/level/level-system";
|
||||||
|
|
||||||
declare module "@react-three/fiber" {
|
declare module "@react-three/fiber" {
|
||||||
interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}
|
interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}
|
||||||
|
|||||||
Reference in New Issue
Block a user