diff --git a/apps/editor/components/editor.tsx b/apps/editor/components/editor.tsx
index 1b699bea..aa1ad1fc 100644
--- a/apps/editor/components/editor.tsx
+++ b/apps/editor/components/editor.tsx
@@ -2,6 +2,7 @@
import {
emitter,
+ initSpatialGridSync,
ItemNode,
sceneRegistry,
useScene,
@@ -32,6 +33,8 @@ import { ToolManager } from "./tools/tool-manager";
const selectedObjects: Object3D[] = [];
+initSpatialGridSync();
+
export default function Editor() {
return (
diff --git a/apps/editor/components/tools/item/item-tool.tsx b/apps/editor/components/tools/item/item-tool.tsx
index 988c99f5..6a1d9803 100644
--- a/apps/editor/components/tools/item/item-tool.tsx
+++ b/apps/editor/components/tools/item/item-tool.tsx
@@ -6,19 +6,21 @@ import {
sceneRegistry,
useRegistry,
useScene,
+ useSpatialQuery,
WallNode,
} from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { useFrame } from "@react-three/fiber";
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";
export const ItemTool: React.FC = () => {
- const cursorRef = useRef(null);
+ const cursorRef = useRef(null!);
const draftItem = useRef(null);
const gridPosition = useRef(new Vector3(0, 0, 0));
const selectedItem = useEditor((state) => state.selectedItem);
+ const { canPlace } = useSpatialQuery();
useEffect(() => {
if (!selectedItem) {
@@ -53,7 +55,7 @@ export const ItemTool: React.FC = () => {
);
cursorRef.current.position.set(
gridPosition.current.x,
- 0.1,
+ 0,
gridPosition.current.z,
);
if (draftItem.current) {
@@ -62,15 +64,29 @@ export const ItemTool: React.FC = () => {
0,
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 { currentLevelId } = useViewer.getState();
- console.log("oh", currentLevelId, draftItem.current);
if (!currentLevelId || !draftItem.current) return;
- console.log("oh");
useScene.temporal.getState().resume();
useScene.getState().updateNode(draftItem.current.id, {
position: [
@@ -88,6 +104,17 @@ export const ItemTool: React.FC = () => {
emitter.on("grid:move", onGridMove);
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 () => {
if (draftItem.current) {
useScene.getState().deleteNode(draftItem.current.id);
@@ -109,8 +136,8 @@ export const ItemTool: React.FC = () => {
return (
-
-
+
+
);
diff --git a/apps/editor/components/ui/item-catalog/catalog-items.tsx b/apps/editor/components/ui/item-catalog/catalog-items.tsx
index 9827f4c4..12ba8dab 100644
--- a/apps/editor/components/ui/item-catalog/catalog-items.tsx
+++ b/apps/editor/components/ui/item-catalog/catalog-items.tsx
@@ -5,16 +5,16 @@ export const CATALOG_ITEMS: AssetInput[] = [
name: "Couch",
thumbnail: "/items/couch-medium/thumbnail.webp",
src: "/items/couch-medium/model.glb",
- scale: [0.4, 0.4, 0.4],
- dimensions: [4, 2, 2],
+ scale: [0.35, 0.35, 0.35],
+ dimensions: [2, 0.8, 1],
},
{
category: "furniture",
name: "Small Couch",
thumbnail: "/items/couch-small/thumbnail.webp",
src: "/items/couch-small/model.glb",
- scale: [0.4, 0.4, 0.4],
- dimensions: [3, 2, 2],
+ scale: [0.35, 0.35, 0.35],
+ dimensions: [1, 0.8, 1],
},
{
category: "furniture",
@@ -154,10 +154,10 @@ export const CATALOG_ITEMS: AssetInput[] = [
name: "Wall Art",
thumbnail: "/items/wall-art-06/thumbnail.webp",
src: "/items/wall-art-06/model.glb",
- offset: [0, 1, 0.15],
+ offset: [0, 0.5, 0],
scale: [1, 1, 1],
rotation: [0, Math.PI, 0],
- dimensions: [2, 2, 1],
+ dimensions: [1, 1, 0.1],
attachTo: "wall-side",
},
{
diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx
index a39d887f..4c3231dc 100644
--- a/apps/editor/store/use-editor.tsx
+++ b/apps/editor/store/use-editor.tsx
@@ -8,6 +8,7 @@ import {
} from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { create } from "zustand";
+import { Asset } from "../../../packages/core/src/schema/nodes/item";
export type Phase = "site" | "structure" | "furnish";
@@ -54,8 +55,8 @@ type EditorState = {
setTool: (tool: Tool | null) => void;
catalogCategory: CatalogCategory | null;
setCatalogCategory: (category: CatalogCategory | null) => void;
- selectedItem: AssetInput | null;
- setSelectedItem: (item: AssetInput) => void;
+ selectedItem: Asset | null;
+ setSelectedItem: (item: Asset) => void;
};
const useEditor = create()((set, get) => ({
diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
new file mode 100644
index 00000000..2ec270af
--- /dev/null
+++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
@@ -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(); // levelId -> grid
+ private wallGrids = new Map(); // levelId -> wall grid
+ private walls = new Map(); // 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();
diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
new file mode 100644
index 00000000..2e8f4872
--- /dev/null
+++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
@@ -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 {
+ // 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]);
+}
diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid.ts b/packages/core/src/hooks/spatial-grid/spatial-grid.ts
new file mode 100644
index 00000000..7da0f362
--- /dev/null
+++ b/packages/core/src/hooks/spatial-grid/spatial-grid.ts
@@ -0,0 +1,161 @@
+type CellKey = `${number},${number}`;
+
+interface GridCell {
+ itemIds: Set;
+}
+
+interface SpatialGridConfig {
+ cellSize: number; // e.g., 0.5 meters = Sims-style half-tile
+}
+
+export class SpatialGrid {
+ private cells = new Map();
+ private itemCells = new Map>(); // 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();
+ 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();
+
+ 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;
+ }
+}
diff --git a/packages/core/src/hooks/spatial-grid/use-spatial-query.ts b/packages/core/src/hooks/spatial-grid/use-spatial-query.ts
new file mode 100644
index 00000000..62577408
--- /dev/null
+++ b/packages/core/src/hooks/spatial-grid/use-spatial-query.ts
@@ -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 };
+}
diff --git a/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts b/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts
new file mode 100644
index 00000000..c89d4e6b
--- /dev/null
+++ b/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts
@@ -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(); // wallId -> placements
+ private itemToWall = new Map(); // 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();
+ }
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index c5744d8b..83c4ed8a 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -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";
diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts
index 7b4d1355..8d99dd5c 100644
--- a/packages/core/src/schema/nodes/item.ts
+++ b/packages/core/src/schema/nodes/item.ts
@@ -16,6 +16,7 @@ const assetSchema = z.object({
});
export type AssetInput = z.input;
+export type Asset = z.infer;
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)
diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx
index 1cb2850f..174dfd51 100644
--- a/packages/core/src/systems/wall/wall-system.tsx
+++ b/packages/core/src/systems/wall/wall-system.tsx
@@ -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"
diff --git a/packages/viewer/src/components/viewer/viewer.tsx b/packages/viewer/src/components/viewer/viewer.tsx
index 13fe3116..1b0f73aa 100644
--- a/packages/viewer/src/components/viewer/viewer.tsx
+++ b/packages/viewer/src/components/viewer/viewer.tsx
@@ -3,10 +3,11 @@
import { Bvh, Environment, OrbitControls } from "@react-three/drei";
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 * as THREE from "three/webgpu";
import { SceneRenderer } from "../renderers/scene-renderer";
+import { LevelSystem } from "../../systems/level/level-system";
declare module "@react-three/fiber" {
interface ThreeElements extends ThreeToJSXElements {}