This commit is contained in:
wass08
2026-01-21 10:03:29 +09:00
parent 14b0e6a98d
commit 5de0818633
53 changed files with 1241 additions and 1420 deletions
+30 -30
View File
@@ -1,49 +1,49 @@
import mitt from "mitt";
import { BuildingNode, ItemNode, WallNode } from "../schema";
import { AnyNode } from "../schema/types";
import mitt from 'mitt'
import type { BuildingNode, ItemNode, WallNode } from '../schema'
import type { AnyNode } from '../schema/types'
// Base event interfaces
export interface GridEvent {
position: [number, number, number];
position: [number, number, number]
}
export interface NodeEvent<T extends AnyNode = AnyNode> {
node: T;
position: [number, number, number];
localPosition: [number, number, number];
normal?: [number, number, number];
stopPropagation: () => void;
node: T
position: [number, number, number]
localPosition: [number, number, number]
normal?: [number, number, number]
stopPropagation: () => void
}
export type WallEvent = NodeEvent<WallNode>;
export type ItemEvent = NodeEvent<ItemNode>;
export type BuildingEvent = NodeEvent<BuildingNode>;
export type WallEvent = NodeEvent<WallNode>
export type ItemEvent = NodeEvent<ItemNode>
export type BuildingEvent = NodeEvent<BuildingNode>
// Event suffixes - exported for use in hooks
export const eventSuffixes = [
"click",
"move",
"enter",
"leave",
"pointerdown",
"pointerup",
"context-menu",
"double-click",
] as const;
'click',
'move',
'enter',
'leave',
'pointerdown',
'pointerup',
'context-menu',
'double-click',
] as const
export type EventSuffix = (typeof eventSuffixes)[number];
export type EventSuffix = (typeof eventSuffixes)[number]
type NodeEvents<T extends string, E> = {
[K in `${T}:${EventSuffix}`]: E;
};
[K in `${T}:${EventSuffix}`]: E
}
type GridEvents = {
[K in `grid:${EventSuffix}`]: GridEvent;
};
[K in `grid:${EventSuffix}`]: GridEvent
}
type EditorEvents = GridEvents &
NodeEvents<"wall", WallEvent> &
NodeEvents<"item", ItemEvent> &
NodeEvents<"building", BuildingEvent>;
NodeEvents<'wall', WallEvent> &
NodeEvents<'item', ItemEvent> &
NodeEvents<'building', BuildingEvent>
export const emitter = mitt<EditorEvents>();
export const emitter = mitt<EditorEvents>()
@@ -1,6 +1,5 @@
import * as THREE from "three";
import { useLayoutEffect } from "react";
import { useLayoutEffect } from 'react'
import type * as THREE from 'three'
export const sceneRegistry = {
// Master lookup: ID -> Object3D
@@ -15,27 +14,27 @@ export const sceneRegistry = {
item: new Set<string>(),
slab: new Set<string>(),
},
};
}
export function useRegistry(
id: string,
type: keyof typeof sceneRegistry.byType,
ref: React.RefObject<THREE.Object3D>
ref: React.RefObject<THREE.Object3D>,
) {
useLayoutEffect(() => {
const obj = ref.current;
if (!obj) return;
const obj = ref.current
if (!obj) return
// 1. Add to master map
sceneRegistry.nodes.set(id, obj);
sceneRegistry.nodes.set(id, obj)
// 2. Add to type-specific set
sceneRegistry.byType[type].add(id);
sceneRegistry.byType[type].add(id)
// 4. Cleanup when component unmounts
return () => {
sceneRegistry.nodes.delete(id);
sceneRegistry.byType[type].delete(id);
};
}, [id, type, ref]);
sceneRegistry.nodes.delete(id)
sceneRegistry.byType[type].delete(id)
}
}, [id, type, ref])
}
@@ -1,64 +1,58 @@
import { AnyNode, ItemNode, WallNode } from "../../schema";
import { SpatialGrid } from "./spatial-grid";
import { WallSpatialGrid } from "./wall-spatial-grid";
import type { 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)
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 }),
);
this.floorGrids.set(levelId, new SpatialGrid({ cellSize: this.cellSize }))
}
return this.floorGrids.get(levelId)!;
return this.floorGrids.get(levelId)!
}
private getWallGrid(levelId: string): WallSpatialGrid {
if (!this.wallGrids.has(levelId)) {
this.wallGrids.set(levelId, new WallSpatialGrid());
this.wallGrids.set(levelId, new WallSpatialGrid())
}
return this.wallGrids.get(levelId)!;
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);
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)
}
private getWallHeight(wallId: string): number {
const wall = this.walls.get(wallId);
return wall?.height ?? 2.5; // Default wall height
const wall = this.walls.get(wallId)
return wall?.height ?? 2.5 // Default wall height
}
// 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"
) {
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 - use parentId as the wall ID
const wallId = item.parentId;
const wallId = item.parentId
if (wallId && this.walls.has(wallId)) {
const wallLength = this.getWallLength(wallId);
const wallLength = this.getWallLength(wallId)
if (wallLength > 0) {
const [width, height] = item.asset.dimensions;
const halfW = width / wallLength / 2;
const [width, height] = item.asset.dimensions
const halfW = width / wallLength / 2
// Calculate t from local X position (position[0] is distance along wall)
const t = item.position[0] / wallLength;
const t = item.position[0] / wallLength
// position[1] is the bottom of the item
this.getWallGrid(levelId).insert({
itemId: item.id,
@@ -67,7 +61,7 @@ export class SpatialGridManager {
tEnd: t + halfW,
yStart: item.position[1],
yEnd: item.position[1] + height,
});
})
}
}
} else if (!item.asset.attachTo) {
@@ -77,31 +71,28 @@ export class SpatialGridManager {
item.position,
item.asset.dimensions,
item.rotation,
);
)
}
}
}
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"
) {
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);
const wallId = item.parentId;
this.getWallGrid(levelId).removeByItemId(item.id)
const wallId = item.parentId
if (wallId && this.walls.has(wallId)) {
const wallLength = this.getWallLength(wallId);
const wallLength = this.getWallLength(wallId)
if (wallLength > 0) {
const [width, height] = item.asset.dimensions;
const halfW = width / wallLength / 2;
const [width, height] = item.asset.dimensions
const halfW = width / wallLength / 2
// Calculate t from local X position (position[0] is distance along wall)
const t = item.position[0] / wallLength;
const t = item.position[0] / wallLength
// position[1] is the bottom of the item
this.getWallGrid(levelId).insert({
itemId: item.id,
@@ -110,7 +101,7 @@ export class SpatialGridManager {
tEnd: t + halfW,
yStart: item.position[1],
yEnd: item.position[1] + height,
});
})
}
}
} else if (!item.asset.attachTo) {
@@ -119,22 +110,22 @@ export class SpatialGridManager {
item.position,
item.asset.dimensions,
item.rotation,
);
)
}
}
}
handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) {
if (nodeType === "wall") {
this.walls.delete(nodeId);
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);
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 [];
return []
}
// Query methods
@@ -145,8 +136,8 @@ export class SpatialGridManager {
rotation: [number, number, number],
ignoreIds?: string[],
) {
const grid = this.getFloorGrid(levelId);
return grid.canPlace(position, dimensions, rotation, ignoreIds);
const grid = this.getFloorGrid(levelId)
return grid.canPlace(position, dimensions, rotation, ignoreIds)
}
/**
@@ -166,14 +157,14 @@ export class SpatialGridManager {
dimensions: [number, number, number],
ignoreIds?: string[],
) {
const wallLength = this.getWallLength(wallId);
const wallLength = this.getWallLength(wallId)
if (wallLength === 0) {
return { valid: false, conflictIds: [] };
return { valid: false, conflictIds: [] }
}
const wallHeight = this.getWallHeight(wallId);
const wallHeight = this.getWallHeight(wallId)
// Convert local X position to parametric t (0-1)
const tCenter = localX / wallLength;
const [itemWidth, itemHeight] = dimensions;
const tCenter = localX / wallLength
const [itemWidth, itemHeight] = dimensions
return this.getWallGrid(levelId).canPlaceOnWall(
wallId,
wallLength,
@@ -183,24 +174,24 @@ export class SpatialGridManager {
localY,
itemHeight,
ignoreIds,
);
)
}
getWallForItem(levelId: string, itemId: string): string | undefined {
return this.getWallGrid(levelId).getWallForItem(itemId);
return this.getWallGrid(levelId).getWallForItem(itemId)
}
clearLevel(levelId: string) {
this.floorGrids.delete(levelId);
this.wallGrids.delete(levelId);
this.floorGrids.delete(levelId)
this.wallGrids.delete(levelId)
}
clear() {
this.floorGrids.clear();
this.wallGrids.clear();
this.walls.clear();
this.floorGrids.clear()
this.wallGrids.clear()
this.walls.clear()
}
}
// Singleton instance
export const spatialGridManager = new SpatialGridManager();
export const spatialGridManager = new SpatialGridManager()
@@ -1,70 +1,67 @@
import { AnyNode } from "../../schema";
import useScene from "../../store/use-scene";
import { spatialGridManager } from "./spatial-grid-manager";
import type { AnyNode } from '../../schema'
import useScene from '../../store/use-scene'
import { spatialGridManager } from './spatial-grid-manager'
export function resolveLevelId(
node: AnyNode,
nodes: Record<string, AnyNode>,
): string {
export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): string {
// If the node itself is a level
if (node.type === "level") return node.id;
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;
let current: AnyNode | undefined = node
while (current) {
if (current.type === "level") return current.id;
if (current.type === 'level') return current.id
// Find parent (you might need to add parentId to your schema or derive it)
if (!current.parentId) {
current = undefined;
current = undefined
} else {
current = nodes[current.parentId];
current = nodes[current.parentId]
}
}
return "default"; // fallback for orphaned items
return 'default' // fallback for orphaned items
}
// Call this once at app initialization
export function initSpatialGridSync() {
const store = useScene;
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);
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);
if (!state.nodes[id as AnyNode['id']]) {
const levelId = resolveLevelId(node, prevState.nodes)
spatialGridManager.handleNodeDeleted(id, node.type, levelId)
}
}
// Detect updated nodes (items with position/rotation/parentId 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") {
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
) {
const levelId = resolveLevelId(node, state.nodes);
spatialGridManager.handleNodeUpdated(node, levelId);
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]);
return a.length === b.length && a.every((v, i) => v === b[i])
}
@@ -1,28 +1,25 @@
type CellKey = `${number},${number}`;
type CellKey = `${number},${number}`
interface GridCell {
itemIds: Set<string>;
itemIds: Set<string>
}
interface SpatialGridConfig {
cellSize: number; // e.g., 0.5 meters = Sims-style half-tile
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
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),
];
return [Math.floor(x / this.config.cellSize), Math.floor(z / this.config.cellSize)]
}
private cellKey(cx: number, cz: number): CellKey {
return `${cx},${cz}`;
return `${cx},${cz}`
}
// Get all cells an item occupies based on its AABB
@@ -33,34 +30,34 @@ export class SpatialGrid {
): 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
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 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 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 [minCx, minCz] = this.posToCell(minX, minZ)
// Use exclusive upper bound: subtract epsilon so exact boundaries don't overlap
// This allows adjacent items (touching but not overlapping) to not conflict
const epsilon = 1e-6;
const [maxCx, maxCz] = this.posToCell(maxX - epsilon, maxZ - epsilon);
const epsilon = 1e-6
const [maxCx, maxCz] = this.posToCell(maxX - epsilon, maxZ - epsilon)
const keys: CellKey[] = [];
const keys: CellKey[] = []
for (let cx = minCx; cx <= maxCx; cx++) {
for (let cz = minCz; cz <= maxCz; cz++) {
keys.push(this.cellKey(cx, cz));
keys.push(this.cellKey(cx, cz))
}
}
return keys;
return keys
}
// Register an item
@@ -70,33 +67,33 @@ export class SpatialGrid {
dimensions: [number, number, number],
rotation: [number, number, number],
) {
const cellKeys = this.getItemCells(position, dimensions, rotation);
const cellKeys = this.getItemCells(position, dimensions, rotation)
this.itemCells.set(itemId, new Set(cellKeys));
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.set(key, { itemIds: new Set() })
}
this.cells.get(key)!.itemIds.add(itemId);
this.cells.get(key)!.itemIds.add(itemId)
}
}
// Remove an item
remove(itemId: string) {
const cellKeys = this.itemCells.get(itemId);
if (!cellKeys) return;
const cellKeys = this.itemCells.get(itemId)
if (!cellKeys) return
for (const key of cellKeys) {
const cell = this.cells.get(key);
const cell = this.cells.get(key)
if (cell) {
cell.itemIds.delete(itemId);
cell.itemIds.delete(itemId)
if (cell.itemIds.size === 0) {
this.cells.delete(key);
this.cells.delete(key)
}
}
}
this.itemCells.delete(itemId);
this.itemCells.delete(itemId)
}
// Update = remove + insert
@@ -106,8 +103,8 @@ export class SpatialGrid {
dimensions: [number, number, number],
rotation: [number, number, number],
) {
this.remove(itemId);
this.insert(itemId, position, dimensions, rotation);
this.remove(itemId)
this.insert(itemId, position, dimensions, rotation)
}
// Query: is this placement valid?
@@ -117,16 +114,16 @@ export class SpatialGrid {
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>();
const cellKeys = this.getItemCells(position, dimensions, rotation)
const ignoreSet = new Set(ignoreIds)
const conflicts = new Set<string>()
for (const key of cellKeys) {
const cell = this.cells.get(key);
const cell = this.cells.get(key)
if (cell) {
for (const id of cell.itemIds) {
if (!ignoreSet.has(id)) {
conflicts.add(id);
conflicts.add(id)
}
}
}
@@ -135,29 +132,29 @@ export class SpatialGrid {
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>();
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));
const cell = this.cells.get(this.cellKey(cx + dx, cz + dz))
if (cell) {
for (const id of cell.itemIds) {
found.add(id);
found.add(id)
}
}
}
}
return [...found];
return [...found]
}
getItemCount(): number {
return this.itemCells.size;
return this.itemCells.size
}
}
@@ -1,31 +1,25 @@
import { useCallback } from "react";
import { LevelNode, WallNode } from "../../schema";
import { spatialGridManager } from "./spatial-grid-manager";
import { useCallback } from 'react'
import type { LevelNode, WallNode } from '../../schema'
import { spatialGridManager } from './spatial-grid-manager'
export function useSpatialQuery() {
const canPlaceOnFloor = useCallback(
(
levelId: LevelNode["id"],
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 spatialGridManager.canPlaceOnFloor(levelId, position, dimensions, rotation, ignoreIds)
},
[],
);
)
const canPlaceOnWall = useCallback(
(
levelId: LevelNode["id"],
wallId: WallNode["id"],
levelId: LevelNode['id'],
wallId: WallNode['id'],
localX: number,
localY: number,
dimensions: [number, number, number],
@@ -38,10 +32,10 @@ export function useSpatialQuery() {
localY,
dimensions,
ignoreIds,
);
)
},
[],
);
)
return { canPlaceOnFloor, canPlaceOnWall };
return { canPlaceOnFloor, canPlaceOnWall }
}
@@ -1,15 +1,15 @@
interface WallItemPlacement {
itemId: string;
wallId: string;
tStart: number; // 0-1 parametric position along wall
tEnd: number;
yStart: number; // height range
yEnd: number;
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)
private wallItems = new Map<string, WallItemPlacement[]>() // wallId -> placements
private itemToWall = new Map<string, string>() // itemId -> wallId (reverse lookup)
canPlaceOnWall(
wallId: string,
@@ -21,82 +21,82 @@ export class WallSpatialGrid {
itemHeight: number,
ignoreIds: string[] = [],
): { valid: boolean; conflictIds: string[] } {
const halfW = itemWidth / wallLength / 2;
const tStart = tCenter - halfW;
const tEnd = tCenter + halfW;
const halfW = itemWidth / wallLength / 2
const tStart = tCenter - halfW
const tEnd = tCenter + halfW
// yBottom is the bottom of the item, so yEnd = yBottom + itemHeight
const yStart = yBottom;
const yEnd = yBottom + itemHeight;
const yStart = yBottom
const yEnd = yBottom + itemHeight
// Check wall boundaries
if (tStart < 0 || tEnd > 1 || yStart < 0 || yEnd > wallHeight) {
return { valid: false, conflictIds: [] };
return { valid: false, conflictIds: [] }
}
const existing = this.wallItems.get(wallId) ?? [];
const ignoreSet = new Set(ignoreIds);
const conflicts: string[] = [];
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;
if (ignoreSet.has(placement.itemId)) continue
const tOverlap = tStart < placement.tEnd && tEnd > placement.tStart;
const yOverlap = yStart < placement.yEnd && yEnd > placement.yStart;
const tOverlap = tStart < placement.tEnd && tEnd > placement.tStart
const yOverlap = yStart < placement.yEnd && yEnd > placement.yStart
if (tOverlap && yOverlap) {
conflicts.push(placement.itemId);
conflicts.push(placement.itemId)
}
}
return { valid: conflicts.length === 0, conflictIds: conflicts };
return { valid: conflicts.length === 0, conflictIds: conflicts }
}
insert(placement: WallItemPlacement) {
const { wallId, itemId } = placement;
const { wallId, itemId } = placement
if (!this.wallItems.has(wallId)) {
this.wallItems.set(wallId, []);
this.wallItems.set(wallId, [])
}
this.wallItems.get(wallId)!.push(placement);
this.itemToWall.set(itemId, wallId);
this.wallItems.get(wallId)!.push(placement)
this.itemToWall.set(itemId, wallId)
}
remove(wallId: string, itemId: string) {
const items = this.wallItems.get(wallId);
const items = this.wallItems.get(wallId)
if (items) {
const idx = items.findIndex((p) => p.itemId === itemId);
if (idx !== -1) items.splice(idx, 1);
const idx = items.findIndex((p) => p.itemId === itemId)
if (idx !== -1) items.splice(idx, 1)
}
this.itemToWall.delete(itemId);
this.itemToWall.delete(itemId)
}
removeByItemId(itemId: string) {
const wallId = this.itemToWall.get(itemId);
const wallId = this.itemToWall.get(itemId)
if (wallId) {
this.remove(wallId, itemId);
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);
const items = this.wallItems.get(wallId) ?? []
const removedIds = items.map((p) => p.itemId)
for (const itemId of removedIds) {
this.itemToWall.delete(itemId);
this.itemToWall.delete(itemId)
}
this.wallItems.delete(wallId);
this.wallItems.delete(wallId)
return removedIds; // Return removed item IDs in case you need to delete them from scene
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);
return this.itemToWall.get(itemId)
}
clear() {
this.wallItems.clear();
this.itemToWall.clear();
this.wallItems.clear()
this.itemToWall.clear()
}
}
+16 -20
View File
@@ -1,27 +1,23 @@
// Store
export { default as useScene } from "./store/use-scene";
export type {
EventSuffix,
GridEvent,
ItemEvent,
NodeEvent,
WallEvent,
} from './events/bus'
// Events
export { emitter, eventSuffixes } from './events/bus'
// Hooks
export {
sceneRegistry,
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 { WallSystem } from "./systems/wall/wall-system";
// Events
export { emitter, eventSuffixes } from "./events/bus";
export type {
ItemEvent,
WallEvent,
NodeEvent,
GridEvent,
EventSuffix,
} from "./events/bus";
} from './hooks/scene-registry/scene-registry'
export { initSpatialGridSync } from './hooks/spatial-grid/spatial-grid-sync'
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
// Schema
export * from "./schema";
export * from './schema'
export { default as useScene } from './store/use-scene'
// Systems
export { WallSystem } from './systems/wall/wall-system'
+14 -15
View File
@@ -1,33 +1,32 @@
import { customAlphabet } from "nanoid";
import { z } from "zod";
import { CameraSchema } from "./camera";
import { customAlphabet } from 'nanoid'
import { z } from 'zod'
import { CameraSchema } from './camera'
const customId = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 16);
const customId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16)
/**
* Material preset name reference
* @example 'white', 'brick', 'wood', 'glass', 'preview-valid'
*/
export const Material = z.string().optional();
export const Material = z.string().optional()
export const generateId = <T extends string>(prefix: T): `${T}_${string}` =>
`${prefix}_${customId()}` as `${T}_${string}`;
`${prefix}_${customId()}` as `${T}_${string}`
export const objectId = <T extends string>(prefix: T) => {
const schema = z.templateLiteral([`${prefix}_`, z.string()]);
const schema = z.templateLiteral([`${prefix}_`, z.string()])
return schema.default(() => generateId(prefix) as z.infer<typeof schema>);
};
export const nodeType = <T extends string>(type: T) =>
z.literal(type).default(type);
return schema.default(() => generateId(prefix) as z.infer<typeof schema>)
}
export const nodeType = <T extends string>(type: T) => z.literal(type).default(type)
export const BaseNode = z.object({
object: z.literal("node").default("node"),
object: z.literal('node').default('node'),
id: z.string(), // objectId('node'), @Aymericr: Thing is if we specify objectId here, when using BaseNode.extend, TS complains that the id is not assignable to the more specific type in the extended node
type: nodeType("node"),
type: nodeType('node'),
name: z.string().optional(),
parentId: z.string().nullable().default(null),
visible: z.boolean().optional().default(true),
camera: CameraSchema.optional(),
metadata: z.json().optional().default({}),
});
})
export type BaseNode = z.infer<typeof BaseNode>;
export type BaseNode = z.infer<typeof BaseNode>
+5 -5
View File
@@ -1,13 +1,13 @@
import { z } from "zod";
import { z } from 'zod'
const Vector3Schema = z.tuple([z.number(), z.number(), z.number()]);
const Vector3Schema = z.tuple([z.number(), z.number(), z.number()])
export const CameraSchema = z.object({
position: Vector3Schema,
target: Vector3Schema,
mode: z.enum(["perspective", "orthographic"]).default("perspective"),
mode: z.enum(['perspective', 'orthographic']).default('perspective'),
fov: z.number().optional(), // For perspective
zoom: z.number().optional(), // For orthographic
});
})
export type Camera = z.infer<typeof CameraSchema>;
export type Camera = z.infer<typeof CameraSchema>
+13 -16
View File
@@ -1,20 +1,17 @@
// Base
export { BaseNode, generateId, objectId, nodeType, Material } from "./base";
// Nodes
export { SiteNode } from "./nodes/site";
export { BuildingNode } from "./nodes/building";
export { LevelNode } from "./nodes/level";
export { WallNode } from "./nodes/wall";
export { ItemNode } from "./nodes/item";
export type { AssetInput } from "./nodes/item";
// Union types
export { AnyNode } from "./types";
export type { AnyNodeType, AnyNodeId } from "./types";
export { BaseNode, generateId, Material, nodeType, objectId } from './base'
// Camera
export { CameraSchema } from "./camera";
export { CameraSchema } from './camera'
export { BuildingNode } from './nodes/building'
export type { AssetInput } from './nodes/item'
export { ItemNode } from './nodes/item'
export { LevelNode } from './nodes/level'
// Nodes
export { SiteNode } from './nodes/site'
export { WallNode } from './nodes/wall'
export type { AnyNodeId, AnyNodeType } from './types'
// Union types
export { AnyNode } from './types'
// Zones
export type { Zone, ZonePolygon } from "./zone";
export type { Zone, ZonePolygon } from './zone'
+9 -9
View File
@@ -1,11 +1,11 @@
import dedent from "dedent";
import { z } from "zod";
import { BaseNode, nodeType, objectId } from "../base";
import { LevelNode } from "./level";
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { LevelNode } from './level'
export const BuildingNode = BaseNode.extend({
id: objectId("building"),
type: nodeType("building"),
id: objectId('building'),
type: nodeType('building'),
children: z.array(LevelNode.shape.id).default([]),
position: 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]),
@@ -15,7 +15,7 @@ export const BuildingNode = BaseNode.extend({
- position: position in site coordinate system
- rotation: rotation in site coordinate system
- children: array of level nodes (each level is a tree of floor and wall nodes)
`
);
`,
)
export type BuildingNode = z.infer<typeof BuildingNode>;
export type BuildingNode = z.infer<typeof BuildingNode>
+12 -12
View File
@@ -1,6 +1,6 @@
import dedent from "dedent";
import { z } from "zod";
import { BaseNode, nodeType, objectId } from "../base";
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
const assetSchema = z.object({
id: z.string(),
@@ -9,22 +9,22 @@ const assetSchema = z.object({
thumbnail: z.string(),
src: z.string(),
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
attachTo: z.enum(["wall", "wall-side", "ceiling"]).optional(),
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
// These are "Corrective" transforms to normalize the GLB
offset: 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]),
scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]),
});
})
export type AssetInput = z.input<typeof assetSchema>;
export type Asset = z.infer<typeof assetSchema>;
export type AssetInput = z.input<typeof assetSchema>
export type Asset = z.infer<typeof assetSchema>
export const ItemNode = BaseNode.extend({
id: objectId("item"),
type: nodeType("item"),
id: objectId('item'),
type: nodeType('item'),
position: 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(),
@@ -42,6 +42,6 @@ export const ItemNode = BaseNode.extend({
- offset: corrective position offset for the model
- rotation: corrective rotation for the model
- scale: corrective scale for the model
`);
`)
export type ItemNode = z.infer<typeof ItemNode>;
export type ItemNode = z.infer<typeof ItemNode>
+9 -9
View File
@@ -1,11 +1,11 @@
import dedent from "dedent";
import { z } from "zod";
import { BaseNode, nodeType, objectId } from "../base";
import { WallNode } from "./wall";
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { WallNode } from './wall'
export const LevelNode = BaseNode.extend({
id: objectId("level"),
type: nodeType("level"),
id: objectId('level'),
type: nodeType('level'),
children: z.array(WallNode.shape.id).default([]),
// Specific props
level: z.number().default(0),
@@ -14,7 +14,7 @@ export const LevelNode = BaseNode.extend({
Level node - used to represent a level in the building
- children: array of floor, wall, ceiling, roof, item nodes
- level: level number
`
);
`,
)
export type LevelNode = z.infer<typeof LevelNode>;
export type LevelNode = z.infer<typeof LevelNode>
+14 -14
View File
@@ -1,16 +1,16 @@
// lib/scenegraph/schema/nodes/site.ts
import dedent from "dedent";
import { z } from "zod";
import { BaseNode, nodeType, objectId } from "../base";
import { BuildingNode } from "./building";
import { ItemNode } from "./item";
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { BuildingNode } from './building'
import { ItemNode } from './item'
// 2D Polygon
const PropertyLineData = z.object({
type: z.literal("polygon"),
type: z.literal('polygon'),
points: z.array(z.tuple([z.number(), z.number()])),
});
})
// 3D Polygon/Mesh
// const TerrainData = z.object({
@@ -19,11 +19,11 @@ const PropertyLineData = z.object({
// })
export const SiteNode = BaseNode.extend({
id: objectId("site"),
type: nodeType("site"),
id: objectId('site'),
type: nodeType('site'),
// Specific props
polygon: PropertyLineData.optional().default({
type: "polygon",
type: 'polygon',
// Default 30x30 square matching GRID_SIZE
points: [
[0, 0],
@@ -34,14 +34,14 @@ export const SiteNode = BaseNode.extend({
}),
// terrain: TerrainData,
children: z
.array(z.discriminatedUnion("type", [BuildingNode, ItemNode]))
.array(z.discriminatedUnion('type', [BuildingNode, ItemNode]))
.default([BuildingNode.parse({})]),
}).describe(
dedent`
Site node - used to represent a site
- polygon: polygon data
- children: array of building and item nodes
`
);
`,
)
export type SiteNode = z.infer<typeof SiteNode>;
export type SiteNode = z.infer<typeof SiteNode>
+9 -9
View File
@@ -1,14 +1,14 @@
import dedent from "dedent";
import { z } from "zod";
import { BaseNode, nodeType, objectId } from "../base";
import { ItemNode } from "./item";
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { ItemNode } from './item'
// import { DoorNode } from "./door";
// import { ItemNode } from "./item";
// import { WindowNode } from "./window";
export const WallNode = BaseNode.extend({
id: objectId("wall"),
type: nodeType("wall"),
id: objectId('wall'),
type: nodeType('wall'),
children: z.array(ItemNode.shape.id).default([]),
// Specific props
thickness: z.number().optional(),
@@ -24,6 +24,6 @@ export const WallNode = BaseNode.extend({
- start: start point of the wall in level coordinate system
- end: end point of the wall in level coordinate system
- size: size of the wall in grid units
`
);
export type WallNode = z.infer<typeof WallNode>;
`,
)
export type WallNode = z.infer<typeof WallNode>
+11 -11
View File
@@ -1,18 +1,18 @@
import z from "zod";
import { BuildingNode } from "./nodes/building";
import { ItemNode } from "./nodes/item";
import { LevelNode } from "./nodes/level";
import { SiteNode } from "./nodes/site";
import { WallNode } from "./nodes/wall";
import z from 'zod'
import { BuildingNode } from './nodes/building'
import { ItemNode } from './nodes/item'
import { LevelNode } from './nodes/level'
import { SiteNode } from './nodes/site'
import { WallNode } from './nodes/wall'
export const AnyNode = z.discriminatedUnion("type", [
export const AnyNode = z.discriminatedUnion('type', [
SiteNode,
BuildingNode,
LevelNode,
WallNode,
ItemNode,
]);
])
export type AnyNode = z.infer<typeof AnyNode>;
export type AnyNodeType = AnyNode["type"];
export type AnyNodeId = AnyNode["id"];
export type AnyNode = z.infer<typeof AnyNode>
export type AnyNodeType = AnyNode['type']
export type AnyNodeId = AnyNode['id']
+11 -11
View File
@@ -1,21 +1,21 @@
import dedent from "dedent";
import { z } from "zod";
import { objectId } from "./base";
import { LevelNode } from "./nodes/level";
import dedent from 'dedent'
import { z } from 'zod'
import { objectId } from './base'
import { LevelNode } from './nodes/level'
// Polygon boundary for zone area - array of [x, z] coordinates
export const ZonePolygon = z.array(z.tuple([z.number(), z.number()]));
export const ZonePolygon = z.array(z.tuple([z.number(), z.number()]))
export const ZoneSchema = z
.object({
id: objectId("zone"),
object: z.literal("zone").default("zone"),
id: objectId('zone'),
object: z.literal('zone').default('zone'),
levelId: LevelNode.shape.id, // Required - must be attached to a level
name: z.string(),
// Polygon boundary - array of [x, z] coordinates defining the zone
polygon: ZonePolygon,
// Visual styling
color: z.string().default("#3b82f6"), // Default blue
color: z.string().default('#3b82f6'), // Default blue
metadata: z.json().optional().default({}),
})
.describe(
@@ -29,7 +29,7 @@ export const ZoneSchema = z
- color: hex color for visual styling
- metadata: zone metadata (optional)
`,
);
)
export type Zone = z.infer<typeof ZoneSchema>;
export type ZonePolygon = z.infer<typeof ZonePolygon>;
export type Zone = z.infer<typeof ZoneSchema>
export type ZonePolygon = z.infer<typeof ZonePolygon>
+50 -55
View File
@@ -1,5 +1,5 @@
import { AnyNode, AnyNodeId } from "../../schema";
import { SceneState } from "../use-scene";
import type { AnyNode, AnyNodeId } from '../../schema'
import type { SceneState } from '../use-scene'
export const createNodesAction = (
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
@@ -7,145 +7,140 @@ export const createNodesAction = (
ops: { node: AnyNode; parentId?: AnyNodeId }[],
) => {
set((state) => {
const nextNodes = { ...state.nodes };
const nextRootIds = [...state.rootNodeIds];
const nextNodes = { ...state.nodes }
const nextRootIds = [...state.rootNodeIds]
for (const { node, parentId } of ops) {
// 1. Assign parentId to the child (Safe because BaseNode has parentId)
const newNode = {
...node,
parentId: parentId ?? null,
};
}
nextNodes[newNode.id] = newNode;
nextNodes[newNode.id] = newNode
// 2. Update the Parent's children list
if (parentId && nextNodes[parentId]) {
const parent = nextNodes[parentId];
const parent = nextNodes[parentId]
// Type Guard: Check if the parent node is a container that supports children
if ("children" in parent && Array.isArray(parent.children)) {
if ('children' in parent && Array.isArray(parent.children)) {
nextNodes[parentId] = {
...parent,
// Use Set to prevent duplicate IDs if createNode is called twice
children: Array.from(
new Set([...parent.children, newNode.id]),
) as any, // We don't verify child types here
};
children: Array.from(new Set([...parent.children, newNode.id])) as any, // We don't verify child types here
}
}
} else if (!parentId) {
// 3. Handle Root nodes
if (!nextRootIds.includes(newNode.id)) {
nextRootIds.push(newNode.id);
nextRootIds.push(newNode.id)
}
}
}
return { nodes: nextNodes, rootNodeIds: nextRootIds };
});
return { nodes: nextNodes, rootNodeIds: nextRootIds }
})
// 4. System Sync
ops.forEach(({ node, parentId }) => {
get().markDirty(node.id);
if (parentId) get().markDirty(parentId);
});
};
get().markDirty(node.id)
if (parentId) get().markDirty(parentId)
})
}
export const updateNodesAction = (
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
get: () => SceneState,
updates: { id: AnyNodeId; data: Partial<AnyNode> }[],
) => {
const parentsToUpdate = new Set<string>();
const parentsToUpdate = new Set<string>()
set((state) => {
const nextNodes = { ...state.nodes };
const nextNodes = { ...state.nodes }
for (const { id, data } of updates) {
const currentNode = nextNodes[id];
if (!currentNode) continue;
const currentNode = nextNodes[id]
if (!currentNode) continue
// Handle Reparenting Logic
if (
data.parentId !== undefined &&
data.parentId !== currentNode.parentId
) {
if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
// 1. Remove from old parent
if (currentNode.parentId && nextNodes[currentNode.parentId]) {
const oldParent = nextNodes[currentNode.parentId] as AnyContainerNode;
const oldParent = nextNodes[currentNode.parentId] as AnyContainerNode
nextNodes[oldParent.id] = {
...oldParent,
children: oldParent.children.filter((childId) => childId !== id),
};
parentsToUpdate.add(oldParent.id);
}
parentsToUpdate.add(oldParent.id)
}
// 2. Add to new parent
if (data.parentId && nextNodes[data.parentId]) {
const newParent = nextNodes[data.parentId] as AnyContainerNode;
const newParent = nextNodes[data.parentId] as AnyContainerNode
nextNodes[newParent.id] = {
...newParent,
children: Array.from(new Set([...newParent.children, id])),
};
parentsToUpdate.add(newParent.id);
}
parentsToUpdate.add(newParent.id)
}
}
// Apply the update
nextNodes[id] = { ...nextNodes[id], ...data };
nextNodes[id] = { ...nextNodes[id], ...data }
}
return { nodes: nextNodes };
});
return { nodes: nextNodes }
})
// Mark dirty
updates.forEach((u) => get().markDirty(u.id));
parentsToUpdate.forEach((pId) => get().markDirty(pId));
};
updates.forEach((u) => get().markDirty(u.id))
parentsToUpdate.forEach((pId) => get().markDirty(pId))
}
export const deleteNodesAction = (
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
get: () => SceneState,
ids: AnyNodeId[],
) => {
const parentsToMarkDirty = new Set<string>();
const parentsToMarkDirty = new Set<string>()
set((state) => {
const nextNodes = { ...state.nodes };
let nextRootIds = [...state.rootNodeIds];
const nextNodes = { ...state.nodes }
let nextRootIds = [...state.rootNodeIds]
for (const id of ids) {
const node = nextNodes[id];
if (!node) continue;
const node = nextNodes[id]
if (!node) continue
// 1. Remove reference from Parent
if (node.parentId && nextNodes[node.parentId]) {
const parent = nextNodes[node.parentId] as AnyContainerNode;
const parent = nextNodes[node.parentId] as AnyContainerNode
if (parent.children) {
nextNodes[parent.id] = {
...parent,
children: parent.children.filter((cid) => cid !== id),
};
parentsToMarkDirty.add(parent.id);
}
parentsToMarkDirty.add(parent.id)
}
}
// 2. Remove from Root list
nextRootIds = nextRootIds.filter((rid) => rid !== id);
nextRootIds = nextRootIds.filter((rid) => rid !== id)
// 3. Delete the node itself
delete nextNodes[id];
delete nextNodes[id]
// Inside the deleteNodes loop
if ("children" in node && node.children.length > 0) {
if ('children' in node && node.children.length > 0) {
// Recursively delete all children first
get().deleteNodes(node.children);
get().deleteNodes(node.children)
}
}
return { nodes: nextNodes, rootNodeIds: nextRootIds };
});
return { nodes: nextNodes, rootNodeIds: nextRootIds }
})
// Notify systems that the parent has changed (e.g. Wall needs to fill a window hole)
parentsToMarkDirty.forEach((pId) => get().markDirty(pId));
};
parentsToMarkDirty.forEach((pId) => get().markDirty(pId))
}
+63 -66
View File
@@ -1,37 +1,37 @@
"use client";
'use client'
import { create } from "zustand";
import { BuildingNode, ItemNode } from "../schema";
import { LevelNode } from "../schema/nodes/level";
import { WallNode } from "../schema/nodes/wall";
import { AnyNode, AnyNodeId } from "../schema/types";
import { temporal } from "zundo";
import * as nodeActions from "./actions/node-actions";
import { temporal } from 'zundo'
import { create } from 'zustand'
import { BuildingNode, ItemNode } from '../schema'
import { LevelNode } from '../schema/nodes/level'
import { WallNode } from '../schema/nodes/wall'
import type { AnyNode, AnyNodeId } from '../schema/types'
import * as nodeActions from './actions/node-actions'
export type SceneState = {
// 1. The Data: A flat dictionary of all nodes
nodes: Record<AnyNodeId, AnyNode>;
nodes: Record<AnyNodeId, AnyNode>
// 2. The Root: Which nodes are at the top level?
rootNodeIds: AnyNodeId[];
rootNodeIds: AnyNodeId[]
// 3. The "Dirty" Set: For the Wall/Physics systems
dirtyNodes: Set<AnyNodeId>;
dirtyNodes: Set<AnyNodeId>
// Actions
loadScene: () => void;
markDirty: (id: AnyNodeId) => void;
clearDirty: (id: AnyNodeId) => void;
loadScene: () => void
markDirty: (id: AnyNodeId) => void
clearDirty: (id: AnyNodeId) => void
createNode: (node: AnyNode, parentId?: AnyNodeId) => void;
createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void;
createNode: (node: AnyNode, parentId?: AnyNodeId) => void
createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void;
updateNodes: (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => void;
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void
updateNodes: (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => void
deleteNode: (id: AnyNodeId) => void;
deleteNodes: (ids: AnyNodeId[]) => void;
};
deleteNode: (id: AnyNodeId) => void
deleteNodes: (ids: AnyNodeId[]) => void
}
// type PartializedStoreState = Pick<SceneState, 'rootNodeIds' | 'nodes'>;
@@ -50,70 +50,70 @@ const useScene = create<SceneState>()(
loadScene: () => {
const building = BuildingNode.parse({
children: [],
});
})
const level0 = LevelNode.parse({
level: 0,
children: [],
});
})
const level1 = LevelNode.parse({
level: 1,
children: [],
});
})
const level2 = LevelNode.parse({
level: 2,
children: [],
});
})
const wall0 = WallNode.parse({
start: [0, 0],
end: [5, 0],
children: [],
parentId: level0.id,
});
})
const wall1 = WallNode.parse({
start: [0, 0],
end: [0, 5],
children: [],
parentId: level0.id,
});
})
const wall2 = WallNode.parse({
start: [5, 5],
end: [0, 5],
children: [],
parentId: level0.id,
});
})
const wall3 = WallNode.parse({
start: [5, 5],
end: [5, 0],
children: [],
parentId: level1.id,
});
})
const window1 = ItemNode.parse({
type: "item",
name: "Window",
type: 'item',
name: 'Window',
position: [2.5, 0.5, 0],
parentId: wall3.id,
asset: {
id: "window-round",
name: "Round Window",
thumbnail: "/items/window-small/thumbnail.png",
category: "windows",
attachTo: "wall",
src: "/items/window-small/model.glb",
id: 'window-round',
name: 'Round Window',
thumbnail: '/items/window-small/thumbnail.png',
category: 'windows',
attachTo: 'wall',
src: '/items/window-small/model.glb',
},
});
})
wall3.children.push(window1.id);
wall3.children.push(window1.id)
level0.children.push(wall0.id, wall1.id, wall2.id);
level1.children.push(wall3.id);
level0.children.push(wall0.id, wall1.id, wall2.id)
level1.children.push(wall3.id)
building.children.push(level0.id, level1.id, level2.id);
building.children.push(level0.id, level1.id, level2.id)
// Define all nodes flat
const nodes: Record<AnyNodeId, AnyNode> = {
@@ -126,34 +126,31 @@ const useScene = create<SceneState>()(
[wall2.id]: wall2,
[wall3.id]: wall3,
[window1.id]: window1,
};
}
// Root nodes are the levels
const rootNodeIds = [building.id];
const rootNodeIds = [building.id]
get().dirtyNodes.add(wall0.id);
get().dirtyNodes.add(wall1.id);
get().dirtyNodes.add(wall2.id);
get().dirtyNodes.add(wall3.id);
set({ nodes, rootNodeIds });
get().dirtyNodes.add(wall0.id)
get().dirtyNodes.add(wall1.id)
get().dirtyNodes.add(wall2.id)
get().dirtyNodes.add(wall3.id)
set({ nodes, rootNodeIds })
},
markDirty: (id) => {
get().dirtyNodes.add(id);
get().dirtyNodes.add(id)
},
clearDirty: (id) => {
get().dirtyNodes.delete(id);
get().dirtyNodes.delete(id)
},
createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),
createNode: (node, parentId) =>
nodeActions.createNodesAction(set, get, [{ node, parentId }]),
createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]),
updateNodes: (updates) =>
nodeActions.updateNodesAction(set, get, updates),
updateNode: (id, data) =>
nodeActions.updateNodesAction(set, get, [{ id, data }]),
updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates),
updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]),
// --- DELETE ---
@@ -163,26 +160,26 @@ const useScene = create<SceneState>()(
}),
{
partialize: (state) => {
const { nodes, rootNodeIds } = state; // Only track nodes and rootNodeIds in history
return { nodes, rootNodeIds };
const { nodes, rootNodeIds } = state // Only track nodes and rootNodeIds in history
return { nodes, rootNodeIds }
},
limit: 50, // Limit to last 50 actions
},
),
);
)
export default useScene;
export default useScene
// Subscribe to the temporal store (Undo/Redo events)
useScene.temporal.subscribe((state, prevState) => {
// Check if we just jumped in time (Undo/Redo)
// If the 'nodes' object changed but it wasn't a normal 'set'
const currentNodes = useScene.getState().nodes;
const currentNodes = useScene.getState().nodes
// Trigger a full scene re-validation
Object.values(currentNodes).forEach((node) => {
if (node.type === "wall") {
useScene.getState().markDirty(node.id);
if (node.type === 'wall') {
useScene.getState().markDirty(node.id)
}
});
});
})
})
+93 -99
View File
@@ -1,19 +1,19 @@
import { useFrame } from "@react-three/fiber";
import * as THREE from "three";
import { sceneRegistry } from "../../hooks/scene-registry/scene-registry";
import { AnyNode, WallNode } from "../../schema";
import useScene from "../../store/use-scene";
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import type { AnyNode, WallNode } from '../../schema'
import useScene from '../../store/use-scene'
export const WallSystem = () => {
const { nodes, dirtyNodes, clearDirty } = useScene();
const { nodes, dirtyNodes, clearDirty } = useScene()
useFrame(() => {
if (dirtyNodes.size === 0) return;
if (dirtyNodes.size === 0) return
dirtyNodes.forEach((id) => {
const node = nodes[id];
if (!node) return;
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh;
const node = nodes[id]
if (!node) return
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
// 1. If a window is dirty, we actually need to redraw its PARENT wall
// if ((node.type === 'window' || node.type === 'door') && node.parentId) {
@@ -22,117 +22,111 @@ export const WallSystem = () => {
// }
// 2. If the wall itself is dirty
if (node.type === "wall" && mesh) {
updateWallGeometry(id);
if (node.type === 'wall' && mesh) {
updateWallGeometry(id)
}
clearDirty(id); // Reset for next frame
});
});
clearDirty(id) // Reset for next frame
})
})
return null;
};
return null
}
// Optimization: Logic moved to a vanilla function so it can be called
// by the Editor or the System without React overhead
function updateWallGeometry(wallId: string) {
const node = useScene.getState().nodes[wallId as WallNode["id"]];
if (!node) return;
if (node.type !== "wall") return;
const node = useScene.getState().nodes[wallId as WallNode['id']]
if (!node) return
if (node.type !== 'wall') return
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh;
if (!mesh) return;
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
if (!mesh) return
const childrenIds = node.children || [];
const childrenIds = node.children || []
const childrenNodes = childrenIds
.map((childId) => useScene.getState().nodes[childId])
.filter((n): n is AnyNode => n !== undefined);
.filter((n): n is AnyNode => n !== undefined)
// Generate visual geometry with holes
const newGeo = generateExtrudedWall(node, childrenNodes);
const newGeo = generateExtrudedWall(node, childrenNodes)
mesh.geometry.dispose();
mesh.geometry = newGeo;
mesh.geometry.dispose()
mesh.geometry = newGeo
// Update collision mesh with solid geometry (no holes)
const collisionMesh = mesh.getObjectByName("collision-mesh") as THREE.Mesh;
const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh
if (collisionMesh) {
const collisionGeo = generateExtrudedWall(node, []); // No children = no holes
collisionMesh.geometry.dispose();
collisionMesh.geometry = collisionGeo;
const collisionGeo = generateExtrudedWall(node, []) // No children = no holes
collisionMesh.geometry.dispose()
collisionMesh.geometry = collisionGeo
}
mesh.position.set(node.start[0], 0, node.start[1]);
mesh.position.set(node.start[0], 0, node.start[1])
// Rotate mesh to look at 'end' point
const angle = Math.atan2(
node.end[1] - node.start[1],
node.end[0] - node.start[0],
);
mesh.rotation.y = -angle;
const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
mesh.rotation.y = -angle
}
export function generateExtrudedWall(
wallNode: WallNode,
childrenNodes: AnyNode[],
) {
export function generateExtrudedWall(wallNode: WallNode, childrenNodes: AnyNode[]) {
// 1. Calculate Wall Dimensions
const start = new THREE.Vector2(wallNode.start[0], wallNode.start[1]);
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.1;
const start = new THREE.Vector2(wallNode.start[0], wallNode.start[1])
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.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"
const shape = new THREE.Shape();
shape.moveTo(0, 0);
shape.lineTo(length, 0);
shape.lineTo(length, height);
shape.lineTo(0, height);
shape.closePath();
const shape = new THREE.Shape()
shape.moveTo(0, 0)
shape.lineTo(length, 0)
shape.lineTo(length, height)
shape.lineTo(0, height)
shape.closePath()
// 3. Process Openings (Holes)
// Compute wall's transform info for converting world coords to wall-local coords
const wallStart: [number, number] = [wallNode.start[0], wallNode.start[1]];
const wallStart: [number, number] = [wallNode.start[0], wallNode.start[1]]
const wallAngle = Math.atan2(
wallNode.end[1] - wallNode.start[1],
wallNode.end[0] - wallNode.start[0],
);
)
// Get the wall mesh's world Y position (from level offset)
const wallMesh = sceneRegistry.nodes.get(wallNode.id) as THREE.Mesh;
const wallWorldY = wallMesh?.getWorldPosition(new THREE.Vector3()).y ?? 0;
const wallMesh = sceneRegistry.nodes.get(wallNode.id) as THREE.Mesh
const wallWorldY = wallMesh?.getWorldPosition(new THREE.Vector3()).y ?? 0
childrenNodes.forEach((child) => {
// Only process items that are intended to be wall cutouts
if (child.type !== "item") return;
if (child.type !== 'item') return
const childMesh = sceneRegistry.nodes.get(child.id);
const childMesh = sceneRegistry.nodes.get(child.id)
if (!childMesh) {
return;
return
}
const cutoutMesh = childMesh.getObjectByName("cutout") as THREE.Mesh;
if (!cutoutMesh) return;
const cutoutMesh = childMesh.getObjectByName('cutout') as THREE.Mesh
if (!cutoutMesh) return
const holePath = createPathFromCutout(cutoutMesh, wallStart, wallAngle, wallWorldY);
const holePath = createPathFromCutout(cutoutMesh, wallStart, wallAngle, wallWorldY)
if (holePath) {
shape.holes.push(holePath);
shape.holes.push(holePath)
}
});
})
// 4. Extrude the Shape into 3D
const geometry = new THREE.ExtrudeGeometry(shape, {
depth: thickness,
bevelEnabled: false,
});
})
// 5. Pivot Alignment
// Center the geometry thickness so the "start/end" line is in the middle of the wall
geometry.translate(0, 0, -thickness / 2);
geometry.translate(0, 0, -thickness / 2)
return geometry;
return geometry
}
/**
@@ -150,72 +144,72 @@ function createPathFromCutout(
wallAngle: number,
wallWorldY: number,
): THREE.Path | null {
const geometry = cutoutMesh.geometry;
if (!geometry) return null;
const geometry = cutoutMesh.geometry
if (!geometry) return null
const positions = geometry.attributes.position;
if (!positions) return null;
const positions = geometry.attributes.position
if (!positions) return null
// Update world matrix to get correct world positions
cutoutMesh.updateWorldMatrix(true, false);
cutoutMesh.updateWorldMatrix(true, false)
// Collect unique vertices (buffer geometry has duplicates for triangulation)
const uniquePoints: THREE.Vector2[] = [];
const seen = new Set<string>();
const v3 = new THREE.Vector3();
const uniquePoints: THREE.Vector2[] = []
const seen = new Set<string>()
const v3 = new THREE.Vector3()
// Precompute sin/cos for rotation
const cosAngle = Math.cos(-wallAngle);
const sinAngle = Math.sin(-wallAngle);
const cosAngle = Math.cos(-wallAngle)
const sinAngle = Math.sin(-wallAngle)
for (let i = 0; i < positions.count; i++) {
v3.fromBufferAttribute(positions, i);
v3.fromBufferAttribute(positions, i)
// Transform to world space
v3.applyMatrix4(cutoutMesh.matrixWorld);
v3.applyMatrix4(cutoutMesh.matrixWorld)
// Transform from world space to wall-local space:
// 1. Translate so wall start is at origin (in XZ plane)
const worldX = v3.x - wallStart[0];
const worldZ = v3.z - wallStart[1];
const worldX = v3.x - wallStart[0]
const worldZ = v3.z - wallStart[1]
// 2. Rotate around Y axis to align wall with local X axis
// The wall shape is drawn on XY plane, so we need:
// - localX = distance along wall
// - localY = height relative to wall's Y position
const localX = worldX * cosAngle - worldZ * sinAngle;
const localY = v3.y - wallWorldY; // Subtract wall's world Y to get local height
const localX = worldX * cosAngle - worldZ * sinAngle
const localY = v3.y - wallWorldY // Subtract wall's world Y to get local height
// Create a key for deduplication (with small tolerance)
const key = `${localX.toFixed(4)},${localY.toFixed(4)}`;
const key = `${localX.toFixed(4)},${localY.toFixed(4)}`
if (!seen.has(key)) {
seen.add(key);
uniquePoints.push(new THREE.Vector2(localX, localY));
seen.add(key)
uniquePoints.push(new THREE.Vector2(localX, localY))
}
}
if (uniquePoints.length < 3) return null;
if (uniquePoints.length < 3) return null
// Sort points in counter-clockwise order around centroid
const centroid = new THREE.Vector2(0, 0);
const centroid = new THREE.Vector2(0, 0)
for (const p of uniquePoints) {
centroid.add(p);
centroid.add(p)
}
centroid.divideScalar(uniquePoints.length);
centroid.divideScalar(uniquePoints.length)
uniquePoints.sort((a, b) => {
const angleA = Math.atan2(a.y - centroid.y, a.x - centroid.x);
const angleB = Math.atan2(b.y - centroid.y, b.x - centroid.x);
return angleA - angleB;
});
const angleA = Math.atan2(a.y - centroid.y, a.x - centroid.x)
const angleB = Math.atan2(b.y - centroid.y, b.x - centroid.x)
return angleA - angleB
})
// Create the path
const path = new THREE.Path();
path.moveTo(uniquePoints[0]?.x || 0, uniquePoints[0]?.y || 0);
const path = new THREE.Path()
path.moveTo(uniquePoints[0]?.x || 0, uniquePoints[0]?.y || 0)
for (let i = 1; i < uniquePoints.length; i++) {
path.lineTo(uniquePoints[i]?.x || 0, uniquePoints[i]?.y || 0);
path.lineTo(uniquePoints[i]?.x || 0, uniquePoints[i]?.y || 0)
}
path.closePath();
path.closePath()
return path;
return path
}
+8 -8
View File
@@ -1,8 +1,8 @@
import js from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier";
import turboPlugin from "eslint-plugin-turbo";
import tseslint from "typescript-eslint";
import onlyWarn from "eslint-plugin-only-warn";
import js from '@eslint/js'
import eslintConfigPrettier from 'eslint-config-prettier'
import onlyWarn from 'eslint-plugin-only-warn'
import turboPlugin from 'eslint-plugin-turbo'
import tseslint from 'typescript-eslint'
/**
* A shared ESLint configuration for the repository.
@@ -18,7 +18,7 @@ export const config = [
turbo: turboPlugin,
},
rules: {
"turbo/no-undeclared-env-vars": "warn",
'turbo/no-undeclared-env-vars': 'warn',
},
},
{
@@ -27,6 +27,6 @@ export const config = [
},
},
{
ignores: ["dist/**"],
ignores: ['dist/**'],
},
];
]
+19 -19
View File
@@ -1,12 +1,12 @@
import js from "@eslint/js";
import { globalIgnores } from "eslint/config";
import eslintConfigPrettier from "eslint-config-prettier";
import tseslint from "typescript-eslint";
import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginReact from "eslint-plugin-react";
import globals from "globals";
import pluginNext from "@next/eslint-plugin-next";
import { config as baseConfig } from "./base.js";
import js from '@eslint/js'
import pluginNext from '@next/eslint-plugin-next'
import { globalIgnores } from 'eslint/config'
import eslintConfigPrettier from 'eslint-config-prettier'
import pluginReact from 'eslint-plugin-react'
import pluginReactHooks from 'eslint-plugin-react-hooks'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import { config as baseConfig } from './base.js'
/**
* A custom ESLint configuration for libraries that use Next.js.
@@ -20,10 +20,10 @@ export const nextJsConfig = [
...tseslint.configs.recommended,
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
]),
{
...pluginReact.configs.flat.recommended,
@@ -36,22 +36,22 @@ export const nextJsConfig = [
},
{
plugins: {
"@next/next": pluginNext,
'@next/next': pluginNext,
},
rules: {
...pluginNext.configs.recommended.rules,
...pluginNext.configs["core-web-vitals"].rules,
...pluginNext.configs['core-web-vitals'].rules,
},
},
{
plugins: {
"react-hooks": pluginReactHooks,
'react-hooks': pluginReactHooks,
},
settings: { react: { version: "detect" } },
settings: { react: { version: 'detect' } },
rules: {
...pluginReactHooks.configs.recommended.rules,
// React scope no longer necessary with new JSX transform.
"react/react-in-jsx-scope": "off",
'react/react-in-jsx-scope': 'off',
},
},
];
]
+11 -11
View File
@@ -1,10 +1,10 @@
import js from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier";
import tseslint from "typescript-eslint";
import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginReact from "eslint-plugin-react";
import globals from "globals";
import { config as baseConfig } from "./base.js";
import js from '@eslint/js'
import eslintConfigPrettier from 'eslint-config-prettier'
import pluginReact from 'eslint-plugin-react'
import pluginReactHooks from 'eslint-plugin-react-hooks'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import { config as baseConfig } from './base.js'
/**
* A custom ESLint configuration for libraries that use React.
@@ -27,13 +27,13 @@ export const config = [
},
{
plugins: {
"react-hooks": pluginReactHooks,
'react-hooks': pluginReactHooks,
},
settings: { react: { version: "detect" } },
settings: { react: { version: 'detect' } },
rules: {
...pluginReactHooks.configs.recommended.rules,
// React scope no longer necessary with new JSX transform.
"react/react-in-jsx-scope": "off",
'react/react-in-jsx-scope': 'off',
},
},
];
]
+8 -11
View File
@@ -1,20 +1,17 @@
"use client";
'use client'
import { ReactNode } from "react";
import type { ReactNode } from 'react'
interface ButtonProps {
children: ReactNode;
className?: string;
appName: string;
children: ReactNode
className?: string
appName: string
}
export const Button = ({ children, className, appName }: ButtonProps) => {
return (
<button
className={className}
onClick={() => alert(`Hello from your ${appName} app!`)}
>
<button className={className} onClick={() => alert(`Hello from your ${appName} app!`)}>
{children}
</button>
);
};
)
}
+6 -6
View File
@@ -1,4 +1,4 @@
import { type JSX } from "react";
import type { JSX } from 'react'
export function Card({
className,
@@ -6,10 +6,10 @@ export function Card({
children,
href,
}: {
className?: string;
title: string;
children: React.ReactNode;
href: string;
className?: string
title: string
children: React.ReactNode
href: string
}): JSX.Element {
return (
<a
@@ -23,5 +23,5 @@ export function Card({
</h2>
<p>{children}</p>
</a>
);
)
}
+4 -4
View File
@@ -1,11 +1,11 @@
import { type JSX } from "react";
import type { JSX } from 'react'
export function Code({
children,
className,
}: {
children: React.ReactNode;
className?: string;
children: React.ReactNode
className?: string
}): JSX.Element {
return <code className={className}>{children}</code>;
return <code className={className}>{children}</code>
}
@@ -1,19 +1,19 @@
import { BuildingNode, useRegistry } from "@pascal-app/core";
import { useRef } from "react";
import { Group } from "three";
import { NodeRenderer } from "../node-renderer";
import { useNodeEvents } from "../../../hooks/use-node-events";
import { type BuildingNode, useRegistry } from '@pascal-app/core'
import { useRef } from 'react'
import type { Group } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { NodeRenderer } from '../node-renderer'
export const BuildingRenderer = ({ node }: { node: BuildingNode }) => {
const ref = useRef<Group>(null!);
const ref = useRef<Group>(null!)
useRegistry(node.id, node.type, ref);
const handlers = useNodeEvents(node, "building");
useRegistry(node.id, node.type, ref)
const handlers = useNodeEvents(node, 'building')
return (
<group ref={ref} {...handlers}>
{node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</group>
);
};
)
}
@@ -1,21 +1,14 @@
import {
AnyNodeId,
emitter,
ItemNode,
useRegistry,
useScene,
} from "@pascal-app/core";
import { Clone } from "@react-three/drei/core/Clone";
import { useGLTF } from "@react-three/drei/core/Gltf";
import { ThreeEvent } from "@react-three/fiber/dist/declarations/src/core/events";
import { Suspense, useCallback, useEffect, useRef } from "react";
import { Group } from "three";
import { useNodeEvents } from "../../../hooks/use-node-events";
import { type AnyNodeId, type ItemNode, useRegistry, useScene } from '@pascal-app/core'
import { Clone } from '@react-three/drei/core/Clone'
import { useGLTF } from '@react-three/drei/core/Gltf'
import { Suspense, useEffect, useRef } from 'react'
import type { Group } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
export const ItemRenderer = ({ node }: { node: ItemNode }) => {
const ref = useRef<Group>(null!);
const ref = useRef<Group>(null!)
useRegistry(node.id, node.type, ref);
useRegistry(node.id, node.type, ref)
return (
<group position={node.position} rotation={node.rotation} ref={ref}>
@@ -23,22 +16,22 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
<ModelRenderer node={node} />
</Suspense>
</group>
);
};
)
}
const ModelRenderer = ({ node }: { node: ItemNode }) => {
const { scene, nodes } = useGLTF(node.asset.src);
const { scene, nodes } = useGLTF(node.asset.src)
if (nodes.cutout) {
nodes.cutout.visible = false;
nodes.cutout.visible = false
}
const handlers = useNodeEvents(node, "item");
const handlers = useNodeEvents(node, 'item')
useEffect(() => {
if (!node.parentId) return;
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId);
}, []);
if (!node.parentId) return
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
}, [node.parentId])
return (
<Clone
@@ -48,5 +41,5 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
rotation={node.asset.rotation}
{...handlers}
/>
);
};
)
}
@@ -1,12 +1,12 @@
import { LevelNode, useRegistry } from "@pascal-app/core";
import { useRef } from "react";
import { Group } from "three";
import { NodeRenderer } from "../node-renderer";
import { type LevelNode, useRegistry } from '@pascal-app/core'
import { useRef } from 'react'
import type { Group } from 'three'
import { NodeRenderer } from '../node-renderer'
export const LevelRenderer = ({ node }: { node: LevelNode }) => {
const ref = useRef<Group>(null!);
const ref = useRef<Group>(null!)
useRegistry(node.id, node.type, ref);
useRegistry(node.id, node.type, ref)
return (
<group ref={ref}>
@@ -18,5 +18,5 @@ export const LevelRenderer = ({ node }: { node: LevelNode }) => {
<NodeRenderer key={childId} nodeId={childId} />
))}
</group>
);
};
)
}
@@ -1,22 +1,22 @@
"use client";
'use client'
import { AnyNode, useScene } from "@pascal-app/core";
import { ItemRenderer } from "./item/item-renderer";
import { LevelRenderer } from "./level/level-renderer";
import { WallRenderer } from "./wall/wall-renderer";
import { BuildingRenderer } from "./building/building-renderer";
import { type AnyNode, useScene } from '@pascal-app/core'
import { BuildingRenderer } from './building/building-renderer'
import { ItemRenderer } from './item/item-renderer'
import { LevelRenderer } from './level/level-renderer'
import { WallRenderer } from './wall/wall-renderer'
export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode["id"] }) => {
const node = useScene((state) => state.nodes[nodeId]);
export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
const node = useScene((state) => state.nodes[nodeId])
if (!node) return null;
if (!node) return null
return (
<>
{node.type === "building" && <BuildingRenderer node={node} />}
{node.type === "level" && <LevelRenderer node={node} />}
{node.type === "item" && <ItemRenderer node={node} />}
{node.type === "wall" && <WallRenderer node={node} />}
{node.type === 'building' && <BuildingRenderer node={node} />}
{node.type === 'level' && <LevelRenderer node={node} />}
{node.type === 'item' && <ItemRenderer node={node} />}
{node.type === 'wall' && <WallRenderer node={node} />}
</>
);
};
)
}
@@ -1,12 +1,10 @@
"use client";
'use client'
import { useScene } from "@pascal-app/core";
import { NodeRenderer } from "./node-renderer";
import { useScene } from '@pascal-app/core'
import { NodeRenderer } from './node-renderer'
export const SceneRenderer = () => {
const rootNodes = useScene((state) => state.rootNodeIds);
const rootNodes = useScene((state) => state.rootNodeIds)
return rootNodes.map((nodeId) => (
<NodeRenderer key={nodeId} nodeId={nodeId} />
));
};
return rootNodes.map((nodeId) => <NodeRenderer key={nodeId} nodeId={nodeId} />)
}
@@ -1,15 +1,15 @@
import { useRegistry, WallNode } from "@pascal-app/core";
import { useRef } from "react";
import { Mesh } from "three";
import { NodeRenderer } from "../node-renderer";
import { useNodeEvents } from "../../../hooks/use-node-events";
import { useRegistry, type WallNode } from '@pascal-app/core'
import { useRef } from 'react'
import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { NodeRenderer } from '../node-renderer'
export const WallRenderer = ({ node }: { node: WallNode }) => {
const ref = useRef<Mesh>(null!);
const ref = useRef<Mesh>(null!)
useRegistry(node.id, "wall", ref);
useRegistry(node.id, 'wall', ref)
const handlers = useNodeEvents(node, "wall");
const handlers = useNodeEvents(node, 'wall')
return (
<mesh ref={ref} castShadow receiveShadow>
@@ -24,5 +24,5 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
<NodeRenderer key={childId} nodeId={childId} />
))}
</mesh>
);
};
)
}
+19 -21
View File
@@ -1,38 +1,36 @@
"use client";
'use client'
import { Bvh, Environment, OrbitControls } from "@react-three/drei";
import { Canvas, ThreeToJSXElements } from "@react-three/fiber";
import { WallSystem } from '@pascal-app/core'
import { Bvh, Environment } from '@react-three/drei'
import { Canvas, extend, type ThreeToJSXElements } from '@react-three/fiber'
import * as THREE from 'three/webgpu'
import { LevelSystem } from '../../systems/level/level-system'
import { SceneRenderer } from '../renderers/scene-renderer'
import PostProcessing from './post-processing'
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";
import PostProcessing from "./post-processing";
declare module "@react-three/fiber" {
declare module '@react-three/fiber' {
interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}
}
extend(THREE as any);
extend(THREE as any)
interface ViewerProps {
children?: React.ReactNode;
children?: React.ReactNode
}
const Viewer: React.FC<ViewerProps> = ({ children }) => {
return (
<Canvas
className={"bg-[#303035]"}
className={'bg-[#303035]'}
gl={async (props) => {
const renderer = new THREE.WebGPURenderer(props as any);
await renderer.init();
return renderer;
const renderer = new THREE.WebGPURenderer(props as any)
await renderer.init()
return renderer
}}
shadows
camera={{ position: [50, 50, 50], fov: 50 }}
>
<color attach="background" args={["#ececec"]} />
<color attach="background" args={['#ececec']} />
<Environment preset="sunset" />
<Bvh>
@@ -46,7 +44,7 @@ const Viewer: React.FC<ViewerProps> = ({ children }) => {
{children}
</Canvas>
);
};
)
}
export default Viewer;
export default Viewer
@@ -1,99 +1,93 @@
import { useFrame, useThree } from "@react-three/fiber";
import { useEffect, useRef } from "react";
import { Color, Object3D } from "three";
import { oscSine, pass, time, uniform } from "three/tsl";
import { outline } from "three/addons/tsl/display/OutlineNode.js";
import { PostProcessing, WebGPURenderer } from "three/webgpu";
import useViewer from "../../store/use-viewer";
import { useFrame, useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import { Color } from 'three'
import { outline } from 'three/addons/tsl/display/OutlineNode.js'
import { oscSine, pass, time, uniform } from 'three/tsl'
import { PostProcessing, type WebGPURenderer } from 'three/webgpu'
import useViewer from '../../store/use-viewer'
const PostProcessingPasses = ({}) => {
const { gl: renderer, scene, camera } = useThree();
const postProcessingRef = useRef<PostProcessing | null>(null);
const { gl: renderer, scene, camera } = useThree()
const postProcessingRef = useRef<PostProcessing | null>(null)
useEffect(() => {
if (!renderer || !scene || !camera) {
return;
return
}
const scenePass = pass(scene, camera);
const scenePass = pass(scene, camera)
function generateSelectedOutlinePass() {
const edgeStrength = uniform(3);
const edgeGlow = uniform(0);
const edgeThickness = uniform(1);
const visibleEdgeColor = uniform(new Color(0xffffff));
const hiddenEdgeColor = uniform(new Color(0xf3ff47));
const edgeStrength = uniform(3)
const edgeGlow = uniform(0)
const edgeThickness = uniform(1)
const visibleEdgeColor = uniform(new Color(0xffffff))
const hiddenEdgeColor = uniform(new Color(0xf3ff47))
const outlinePass = outline(scene, camera, {
selectedObjects: useViewer.getState().outliner.selectedObjects,
edgeGlow,
edgeThickness,
});
const { visibleEdge, hiddenEdge } = outlinePass;
})
const { visibleEdge, hiddenEdge } = outlinePass
const outlineColor = visibleEdge
.mul(visibleEdgeColor)
.add(hiddenEdge.mul(hiddenEdgeColor))
.mul(edgeStrength);
.mul(edgeStrength)
return outlineColor;
return outlineColor
}
function generateHoverOutlinePass() {
const edgeStrength = uniform(5);
const edgeGlow = uniform(0.5);
const edgeThickness = uniform(1.5);
const pulsePeriod = uniform(3);
const visibleEdgeColor = uniform(new Color(0x00aaff));
const hiddenEdgeColor = uniform(new Color(0xf3ff47));
const edgeStrength = uniform(5)
const edgeGlow = uniform(0.5)
const edgeThickness = uniform(1.5)
const pulsePeriod = uniform(3)
const visibleEdgeColor = uniform(new Color(0x00aaff))
const hiddenEdgeColor = uniform(new Color(0xf3ff47))
const outlinePass = outline(scene, camera, {
selectedObjects: useViewer.getState().outliner.hoveredObjects,
edgeGlow,
edgeThickness,
});
const { visibleEdge, hiddenEdge } = outlinePass;
})
const { visibleEdge, hiddenEdge } = outlinePass
const period = time.div(pulsePeriod).mul(2);
const osc = oscSine(period).mul(0.5).add(0.5); // osc [ 0.5, 1.0 ]
const period = time.div(pulsePeriod).mul(2)
const osc = oscSine(period).mul(0.5).add(0.5) // osc [ 0.5, 1.0 ]
const outlineColor = visibleEdge
.mul(visibleEdgeColor)
.add(hiddenEdge.mul(hiddenEdgeColor))
.mul(edgeStrength);
const outlinePulse = pulsePeriod
.greaterThan(0)
.select(outlineColor.mul(osc), outlineColor);
return outlinePulse;
.mul(edgeStrength)
const outlinePulse = pulsePeriod.greaterThan(0).select(outlineColor.mul(osc), outlineColor)
return outlinePulse
}
// Setup post-processing
const postProcessing = new PostProcessing(
renderer as unknown as WebGPURenderer,
);
const postProcessing = new PostProcessing(renderer as unknown as WebGPURenderer)
const selectedOutlinePass = generateSelectedOutlinePass();
const hoverOutlinePass = generateHoverOutlinePass();
const selectedOutlinePass = generateSelectedOutlinePass()
const hoverOutlinePass = generateHoverOutlinePass()
postProcessing.outputNode = selectedOutlinePass
.add(hoverOutlinePass)
.add(scenePass);
postProcessingRef.current = postProcessing;
postProcessing.outputNode = selectedOutlinePass.add(hoverOutlinePass).add(scenePass)
postProcessingRef.current = postProcessing
return () => {
if (postProcessingRef.current) {
postProcessingRef.current.dispose();
postProcessingRef.current.dispose()
}
postProcessingRef.current = null;
};
}, [renderer, scene, camera]);
postProcessingRef.current = null
}
}, [renderer, scene, camera])
useFrame(() => {
if (postProcessingRef.current) {
postProcessingRef.current.render();
postProcessingRef.current.render()
}
}, 1);
}, 1)
return null;
};
return null
}
export default PostProcessingPasses;
export default PostProcessingPasses
+18 -18
View File
@@ -1,33 +1,33 @@
import { emitter, EventSuffix, GridEvent } from "@pascal-app/core";
import { ThreeEvent } from "@react-three/fiber";
import { type EventSuffix, emitter, type GridEvent } from '@pascal-app/core'
import type { ThreeEvent } from '@react-three/fiber'
export function useGridEvents() {
const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {
const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`;
const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`
const payload: GridEvent = {
position: [e.point.x, e.point.y, e.point.z],
};
}
emitter.emit(eventKey, payload);
};
emitter.emit(eventKey, payload)
}
return {
onPointerDown: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return;
emit("pointerdown", e);
if (e.button !== 0) return
emit('pointerdown', e)
},
onPointerUp: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return;
emit("pointerup", e);
if (e.button !== 0) return
emit('pointerup', e)
},
onClick: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return;
emit("click", e);
if (e.button !== 0) return
emit('click', e)
},
onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit("enter", e),
onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit("leave", e),
onPointerMove: (e: ThreeEvent<PointerEvent>) => emit("move", e),
onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit("double-click", e),
onContextMenu: (e: ThreeEvent<PointerEvent>) => emit("context-menu", e),
};
onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit('enter', e),
onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit('leave', e),
onPointerMove: (e: ThreeEvent<PointerEvent>) => emit('move', e),
onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit('double-click', e),
onContextMenu: (e: ThreeEvent<PointerEvent>) => emit('context-menu', e),
}
}
+33 -38
View File
@@ -1,60 +1,55 @@
import {
BuildingNode,
type BuildingNode,
type EventSuffix,
emitter,
EventSuffix,
ItemEvent,
ItemNode,
WallEvent,
WallNode,
} from "@pascal-app/core";
import { ThreeEvent } from "@react-three/fiber";
import { BuildingEvent } from "../../../core/src/events/bus";
type ItemEvent,
type ItemNode,
type WallEvent,
type WallNode,
} from '@pascal-app/core'
import type { ThreeEvent } from '@react-three/fiber'
import type { BuildingEvent } from '../../../core/src/events/bus'
type NodeConfig = {
item: { node: ItemNode; event: ItemEvent };
wall: { node: WallNode; event: WallEvent };
building: { node: BuildingNode; event: BuildingEvent };
};
item: { node: ItemNode; event: ItemEvent }
wall: { node: WallNode; event: WallEvent }
building: { node: BuildingNode; event: BuildingEvent }
}
type NodeType = keyof NodeConfig;
type NodeType = keyof NodeConfig
export function useNodeEvents<T extends NodeType>(
node: NodeConfig[T]["node"],
type: T,
) {
export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], type: T) {
const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {
const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`;
const localPoint = e.object.worldToLocal(e.point.clone());
const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`
const localPoint = e.object.worldToLocal(e.point.clone())
const payload = {
node,
position: [e.point.x, e.point.y, e.point.z],
localPosition: [localPoint.x, localPoint.y, localPoint.z],
normal: e.face
? [e.face.normal.x, e.face.normal.y, e.face.normal.z]
: undefined,
normal: e.face ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] : undefined,
stopPropagation: () => e.stopPropagation(),
} as NodeConfig[T]["event"];
} as NodeConfig[T]['event']
emitter.emit(eventKey, payload);
};
emitter.emit(eventKey, payload)
}
return {
onPointerDown: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return;
emit("pointerdown", e);
if (e.button !== 0) return
emit('pointerdown', e)
},
onPointerUp: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return;
emit("pointerup", e);
if (e.button !== 0) return
emit('pointerup', e)
},
onClick: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return;
emit("click", e);
if (e.button !== 0) return
emit('click', e)
},
onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit("enter", e),
onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit("leave", e),
onPointerMove: (e: ThreeEvent<PointerEvent>) => emit("move", e),
onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit("double-click", e),
onContextMenu: (e: ThreeEvent<PointerEvent>) => emit("context-menu", e),
};
onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit('enter', e),
onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit('leave', e),
onPointerMove: (e: ThreeEvent<PointerEvent>) => emit('move', e),
onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit('double-click', e),
onContextMenu: (e: ThreeEvent<PointerEvent>) => emit('context-menu', e),
}
}
+3 -5
View File
@@ -1,5 +1,3 @@
export { default as Viewer } from "./components/viewer";
export { default as useViewer } from "./store/use-viewer";
export { useGridEvents } from "./hooks/use-grid-events";
export { default as Viewer } from './components/viewer'
export { useGridEvents } from './hooks/use-grid-events'
export { default as useViewer } from './store/use-viewer'
+30 -30
View File
@@ -1,58 +1,58 @@
"use client";
'use client'
import { BuildingNode, ItemNode, LevelNode, Zone } from "@pascal-app/core";
import { Object3D } from "three";
import type { BuildingNode, ItemNode, LevelNode, Zone } from '@pascal-app/core'
import type { Object3D } from 'three'
import { create } from "zustand";
import { create } from 'zustand'
type SelectionPath = {
buildingId: BuildingNode["id"] | null;
levelId: LevelNode["id"] | null;
zoneId: Zone["id"] | null;
selectedIds: ItemNode["id"][]; // For items/assets (multi-select)
};
buildingId: BuildingNode['id'] | null
levelId: LevelNode['id'] | null
zoneId: Zone['id'] | null
selectedIds: ItemNode['id'][] // For items/assets (multi-select)
}
type Outliner = {
selectedObjects: Object3D[];
hoveredObjects: Object3D[];
};
selectedObjects: Object3D[]
hoveredObjects: Object3D[]
}
type ViewerState = {
selection: SelectionPath;
levelMode: "stacked" | "exploded" | "solo" | "manual";
selection: SelectionPath
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
// Actions
setLevelMode: (mode: "stacked" | "exploded" | "solo" | "manual") => void;
setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void
// Smart selection update
setSelection: (updates: Partial<SelectionPath>) => void;
resetSelection: () => void;
setSelection: (updates: Partial<SelectionPath>) => void
resetSelection: () => void
outliner: Outliner; // No setter as we will manipulate directly the arrays
};
outliner: Outliner // No setter as we will manipulate directly the arrays
}
const useViewer = create<ViewerState>()((set, get) => ({
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
levelMode: "stacked",
levelMode: 'stacked',
setLevelMode: (mode) => set({ levelMode: mode }),
setSelection: (updates) =>
set((state) => {
const newSelection = { ...state.selection, ...updates };
const newSelection = { ...state.selection, ...updates }
// Hierarchy Guard: If we change a high-level parent, reset the children
if (updates.buildingId !== undefined) {
newSelection.levelId = null;
newSelection.zoneId = null;
newSelection.selectedIds = [];
newSelection.levelId = null
newSelection.zoneId = null
newSelection.selectedIds = []
} else if (updates.levelId !== undefined) {
newSelection.zoneId = null;
newSelection.selectedIds = [];
newSelection.zoneId = null
newSelection.selectedIds = []
} else if (updates.zoneId !== undefined) {
newSelection.selectedIds = [];
newSelection.selectedIds = []
}
return { selection: newSelection };
return { selection: newSelection }
}),
resetSelection: () =>
@@ -66,6 +66,6 @@ const useViewer = create<ViewerState>()((set, get) => ({
}),
outliner: { selectedObjects: [], hoveredObjects: [] },
}));
}))
export default useViewer;
export default useViewer
@@ -1,24 +1,24 @@
import { LevelNode, sceneRegistry, useScene } from "@pascal-app/core";
import { useFrame } from "@react-three/fiber";
import { lerp } from "three/src/math/MathUtils.js";
import useViewer from "../../store/use-viewer";
import { type LevelNode, sceneRegistry, useScene } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { lerp } from 'three/src/math/MathUtils.js'
import useViewer from '../../store/use-viewer'
const LEVEL_HEIGHT = 2.5;
const EXPLODED_GAP = 5;
const LEVEL_HEIGHT = 2.5
const EXPLODED_GAP = 5
export const LevelSystem = () => {
useFrame((_, delta) => {
const levelMode = useViewer.getState().levelMode;
const levelMode = useViewer.getState().levelMode
sceneRegistry.byType.level.forEach((levelId) => {
const obj = sceneRegistry.nodes.get(levelId);
const obj = sceneRegistry.nodes.get(levelId)
if (obj) {
const level = useScene.getState().nodes[levelId as LevelNode["id"]];
const level = useScene.getState().nodes[levelId as LevelNode['id']]
const targetY =
((level as any).level || 0) *
(LEVEL_HEIGHT + (levelMode === "stacked" ? 0 : EXPLODED_GAP));
obj.position.y = lerp(obj.position.y, targetY, delta * 3);
(LEVEL_HEIGHT + (levelMode === 'stacked' ? 0 : EXPLODED_GAP))
obj.position.y = lerp(obj.position.y, targetY, delta * 3)
}
});
});
return null;
};
})
})
return null
}