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
+15 -17
View File
@@ -1,31 +1,29 @@
import type { Metadata } from "next"; import type { Metadata } from 'next'
import localFont from "next/font/local"; import localFont from 'next/font/local'
import "./globals.css"; import './globals.css'
const geistSans = localFont({ const geistSans = localFont({
src: "./fonts/GeistVF.woff", src: './fonts/GeistVF.woff',
variable: "--font-geist-sans", variable: '--font-geist-sans',
}); })
const geistMono = localFont({ const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff", src: './fonts/GeistMonoVF.woff',
variable: "--font-geist-mono", variable: '--font-geist-mono',
}); })
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: 'Create Next App',
description: "Generated by create next app", description: 'Generated by create next app',
}; }
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}> <body className={`${geistSans.variable} ${geistMono.variable}`}>{children}</body>
{children}
</body>
</html> </html>
); )
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import Editor from "../components/editor"; import Editor from '../components/editor'
export default function Home() { export default function Home() {
return ( return (
@@ -7,5 +7,5 @@ export default function Home() {
<Editor /> <Editor />
</div> </div>
</div> </div>
); )
} }
@@ -1,46 +1,38 @@
"use client"; 'use client'
import { sceneRegistry } from "@pascal-app/core"; import { sceneRegistry } from '@pascal-app/core'
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from "@react-three/drei"; import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from 'react'
import { Vector3 } from "three"; import { Vector3 } from 'three'
const currentTarget = new Vector3(); const currentTarget = new Vector3()
export const CustomCameraControls = () => { export const CustomCameraControls = () => {
const controls = useRef<CameraControlsImpl>(null!); const controls = useRef<CameraControlsImpl>(null!)
const currentLevelId = useViewer((state) => state.selection.levelId); const currentLevelId = useViewer((state) => state.selection.levelId)
const firstLoad = useRef(true); const firstLoad = useRef(true)
useEffect(() => { useEffect(() => {
let targetY = 0; let targetY = 0
if (currentLevelId) { if (currentLevelId) {
const levelMesh = sceneRegistry.nodes.get(currentLevelId); const levelMesh = sceneRegistry.nodes.get(currentLevelId)
if (levelMesh) { if (levelMesh) {
targetY = levelMesh.position.y; targetY = levelMesh.position.y
} }
} }
if (firstLoad.current) { if (firstLoad.current) {
firstLoad.current = false; firstLoad.current = false
(controls.current as CameraControlsImpl).setLookAt( ;(controls.current as CameraControlsImpl).setLookAt(20, 20, 20, 0, 0, 0, true)
20,
20,
20,
0,
0,
0,
true,
);
} }
(controls.current as CameraControlsImpl).getTarget(currentTarget); ;(controls.current as CameraControlsImpl).getTarget(currentTarget)
(controls.current as CameraControlsImpl).moveTo( ;(controls.current as CameraControlsImpl).moveTo(
currentTarget.x, currentTarget.x,
targetY, targetY,
currentTarget.z, currentTarget.z,
true, true,
); )
}, [currentLevelId]); }, [currentLevelId])
// Configure mouse buttons based on control mode and camera mode // Configure mouse buttons based on control mode and camera mode
const mouseButtons = useMemo(() => { const mouseButtons = useMemo(() => {
@@ -49,15 +41,15 @@ export const CustomCameraControls = () => {
// cameraMode === 'orthographic' // cameraMode === 'orthographic'
// ? CameraControlsImpl.ACTION.ZOOM // ? CameraControlsImpl.ACTION.ZOOM
// : CameraControlsImpl.ACTION.DOLLY // : CameraControlsImpl.ACTION.DOLLY
const wheelAction = CameraControlsImpl.ACTION.DOLLY; const wheelAction = CameraControlsImpl.ACTION.DOLLY
return { return {
left: CameraControlsImpl.ACTION.NONE, left: CameraControlsImpl.ACTION.NONE,
middle: CameraControlsImpl.ACTION.SCREEN_PAN, middle: CameraControlsImpl.ACTION.SCREEN_PAN,
right: CameraControlsImpl.ACTION.ROTATE, right: CameraControlsImpl.ACTION.ROTATE,
wheel: wheelAction, wheel: wheelAction,
}; }
}, []); }, [])
return <CameraControls ref={controls} mouseButtons={mouseButtons} />; return <CameraControls ref={controls} mouseButtons={mouseButtons} />
}; }
+68 -96
View File
@@ -1,42 +1,23 @@
"use client"; 'use client'
import { import { initSpatialGridSync, sceneRegistry, useScene } from '@pascal-app/core'
emitter, import { useGridEvents, useViewer, Viewer } from '@pascal-app/viewer'
initSpatialGridSync,
ItemNode,
sceneRegistry,
useRegistry,
useScene,
WallNode,
} from "@pascal-app/core";
import { useGridEvents, useViewer, Viewer } from "@pascal-app/viewer";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from "react"; import { useMemo, useRef } from 'react'
import { Color, MathUtils, Mesh, Object3D, Vector3 } from "three"; import { MathUtils, type Mesh } from 'three'
import { import { color, float, fract, fwidth, mix, positionLocal } from 'three/tsl'
color, import { MeshBasicNodeMaterial } from 'three/webgpu'
float, import { ToolManager } from '../tools/tool-manager'
fract, import { ActionMenu } from '../ui/action-menu'
fwidth, import { SidebarProvider } from '../ui/primitives/sidebar'
mix, import { AppSidebar } from '../ui/sidebar/app-sidebar'
oscSine, import { CustomCameraControls } from './custom-camera-controls'
pass, import { SelectionManager } from './selection-manager'
positionLocal,
time,
uniform,
} from "three/tsl";
import { MeshBasicNodeMaterial, PostProcessing } from "three/webgpu";
import { ActionMenu } from "../ui/action-menu";
import { ToolManager } from "../tools/tool-manager";
import { AppSidebar } from "../ui/sidebar/app-sidebar";
import { SidebarProvider } from "../ui/primitives/sidebar";
import { CustomCameraControls } from "./custom-camera-controls";
import { SelectionManager } from "./selection-manager";
initSpatialGridSync(); initSpatialGridSync()
useScene.getState().loadScene(); useScene.getState().loadScene()
export default function Editor() { export default function Editor() {
return ( return (
@@ -57,18 +38,18 @@ export default function Editor() {
<CustomCameraControls /> <CustomCameraControls />
</Viewer> </Viewer>
</div> </div>
); )
} }
const TestUndo = () => { const TestUndo = () => {
const { undo, redo, futureStates, pastStates } = useScene.temporal.getState(); const { undo, redo, futureStates, pastStates } = useScene.temporal.getState()
return ( return (
<div className="absolute top-4 right-4 z-10 flex gap-2"> <div className="absolute top-4 right-4 z-10 flex gap-2">
<button <button
className="px-4 py-2 rounded bg-white" className="px-4 py-2 rounded bg-white"
onClick={() => { onClick={() => {
undo(); undo()
}} }}
> >
Undo Undo
@@ -76,86 +57,86 @@ const TestUndo = () => {
<button <button
className="px-4 py-2 rounded bg-white" className="px-4 py-2 rounded bg-white"
onClick={() => { onClick={() => {
redo(); redo()
}} }}
> >
Redo Redo
</button> </button>
</div> </div>
); )
}; }
const Grid = ({ const Grid = ({
cellSize = 0.5, cellSize = 0.5,
cellThickness = 0.5, cellThickness = 0.5,
cellColor = "#888888", cellColor = '#888888',
sectionSize = 1, sectionSize = 1,
sectionThickness = 1, sectionThickness = 1,
sectionColor = "#000000", sectionColor = '#000000',
fadeDistance = 100, fadeDistance = 100,
fadeStrength = 1, fadeStrength = 1,
}: { }: {
cellSize?: number; cellSize?: number
cellThickness?: number; cellThickness?: number
cellColor?: string; cellColor?: string
sectionSize?: number; sectionSize?: number
sectionThickness?: number; sectionThickness?: number
sectionColor?: string; sectionColor?: string
fadeDistance?: number; fadeDistance?: number
fadeStrength?: number; fadeStrength?: number
}) => { }) => {
const material = useMemo(() => { const material = useMemo(() => {
// Use xy since plane geometry is in XY space (before rotation) // Use xy since plane geometry is in XY space (before rotation)
const pos = positionLocal.xy; const pos = positionLocal.xy
// Grid line function using fwidth for anti-aliasing // Grid line function using fwidth for anti-aliasing
// Returns 1 on grid lines, 0 elsewhere // Returns 1 on grid lines, 0 elsewhere
const getGrid = (size: number, thickness: number) => { const getGrid = (size: number, thickness: number) => {
const r = pos.div(size); const r = pos.div(size)
const fw = fwidth(r); const fw = fwidth(r)
// Distance to nearest grid line for each axis // Distance to nearest grid line for each axis
const grid = fract(r.sub(0.5)).sub(0.5).abs(); const grid = fract(r.sub(0.5)).sub(0.5).abs()
// Anti-aliased step: divide by fwidth and clamp // Anti-aliased step: divide by fwidth and clamp
const lineX = float(1).sub( const lineX = float(1).sub(
grid.x grid.x
.div(fw.x) .div(fw.x)
.add(1 - thickness) .add(1 - thickness)
.min(1), .min(1),
); )
const lineY = float(1).sub( const lineY = float(1).sub(
grid.y grid.y
.div(fw.y) .div(fw.y)
.add(1 - thickness) .add(1 - thickness)
.min(1), .min(1),
); )
// Combine both axes - max gives us lines in both directions // Combine both axes - max gives us lines in both directions
return lineX.max(lineY); return lineX.max(lineY)
}; }
const g1 = getGrid(cellSize, cellThickness); const g1 = getGrid(cellSize, cellThickness)
const g2 = getGrid(sectionSize, sectionThickness); const g2 = getGrid(sectionSize, sectionThickness)
// Distance fade from center // Distance fade from center
const dist = pos.length(); const dist = pos.length()
const fade = float(1).sub(dist.div(fadeDistance).min(1)).pow(fadeStrength); const fade = float(1).sub(dist.div(fadeDistance).min(1)).pow(fadeStrength)
// Mix colors based on section grid // Mix colors based on section grid
const gridColor = mix( const gridColor = mix(
color(cellColor), color(cellColor),
color(sectionColor), color(sectionColor),
float(sectionThickness).mul(g2).min(1), float(sectionThickness).mul(g2).min(1),
); )
// Combined alpha // Combined alpha
const alpha = g1.add(g2).mul(fade); const alpha = g1.add(g2).mul(fade)
const finalAlpha = mix(alpha.mul(0.75), alpha, g2); const finalAlpha = mix(alpha.mul(0.75), alpha, g2)
return new MeshBasicNodeMaterial({ return new MeshBasicNodeMaterial({
transparent: true, transparent: true,
colorNode: gridColor, colorNode: gridColor,
opacityNode: finalAlpha, opacityNode: finalAlpha,
depthWrite: false, depthWrite: false,
}); })
}, [ }, [
cellSize, cellSize,
cellThickness, cellThickness,
@@ -165,61 +146,52 @@ const Grid = ({
sectionColor, sectionColor,
fadeDistance, fadeDistance,
fadeStrength, fadeStrength,
]); ])
const handlers = useGridEvents(); const handlers = useGridEvents()
const gridRef = useRef<Mesh>(null!); const gridRef = useRef<Mesh>(null!)
useFrame((_, delta) => { useFrame((_, delta) => {
const currentLevelId = useViewer.getState().selection.levelId; const currentLevelId = useViewer.getState().selection.levelId
let targetY = 0; let targetY = 0
if (currentLevelId) { if (currentLevelId) {
const levelMesh = sceneRegistry.nodes.get(currentLevelId); const levelMesh = sceneRegistry.nodes.get(currentLevelId)
if (levelMesh) { if (levelMesh) {
targetY = levelMesh.position.y; targetY = levelMesh.position.y
} }
} }
gridRef.current.position.y = MathUtils.lerp( gridRef.current.position.y = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
gridRef.current.position.y, })
targetY,
12 * delta,
);
});
return ( return (
<mesh <mesh rotation-x={-Math.PI / 2} material={material} {...handlers} ref={gridRef}>
rotation-x={-Math.PI / 2}
material={material}
{...handlers}
ref={gridRef}
>
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} /> <planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
</mesh> </mesh>
); )
}; }
const LevelModeSwitcher = () => { const LevelModeSwitcher = () => {
const setLevelMode = useViewer((state) => state.setLevelMode); const setLevelMode = useViewer((state) => state.setLevelMode)
const levelMode = useViewer((state) => state.levelMode); const levelMode = useViewer((state) => state.levelMode)
return ( return (
<div className="absolute top-4 left-4 z-10 flex gap-2"> <div className="absolute top-4 left-4 z-10 flex gap-2">
<button <button
className={`px-4 py-2 rounded ${ className={`px-4 py-2 rounded ${
levelMode === "exploded" ? "bg-blue-500 text-white" : "bg-white" levelMode === 'exploded' ? 'bg-blue-500 text-white' : 'bg-white'
}`} }`}
onClick={() => setLevelMode("exploded")} onClick={() => setLevelMode('exploded')}
> >
Exploded Exploded
</button> </button>
<button <button
className={`px-4 py-2 rounded ${ className={`px-4 py-2 rounded ${
levelMode === "stacked" ? "bg-blue-500 text-white" : "bg-white" levelMode === 'stacked' ? 'bg-blue-500 text-white' : 'bg-white'
}`} }`}
onClick={() => setLevelMode("stacked")} onClick={() => setLevelMode('stacked')}
> >
Stacked Stacked
</button> </button>
</div> </div>
); )
}; }
@@ -1,36 +1,13 @@
import { import {
emitter, emitter,
initSpatialGridSync, type ItemNode,
ItemNode,
sceneRegistry, sceneRegistry,
useRegistry,
useScene, useScene,
WallNode, type WallNode,
} from "@pascal-app/core"; } from "@pascal-app/core";
import { useGridEvents, useViewer, Viewer } from "@pascal-app/viewer"; import { useViewer } from "@pascal-app/viewer";
import { useFrame, useThree } from "@react-three/fiber"; import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { Color, MathUtils, Mesh, Object3D, Vector3 } from "three";
import {
color,
float,
fract,
fwidth,
mix,
oscSine,
pass,
positionLocal,
time,
uniform,
} from "three/tsl";
import { MeshBasicNodeMaterial, PostProcessing } from "three/webgpu";
import { ActionMenu } from "../ui/action-menu";
import { ToolManager } from "../tools/tool-manager";
import { AppSidebar } from "../ui/sidebar/app-sidebar";
import { SidebarProvider } from "../ui/primitives/sidebar";
import { CustomCameraControls } from "./custom-camera-controls";
export const SelectionManager = () => { export const SelectionManager = () => {
const selectedItemId = useRef<ItemNode["id"] | WallNode["id"]>(null); const selectedItemId = useRef<ItemNode["id"] | WallNode["id"]>(null);
+139 -162
View File
@@ -1,20 +1,19 @@
import useEditor from "@/store/use-editor";
import { import {
emitter, emitter,
GridEvent, type GridEvent,
ItemNode, ItemNode,
sceneRegistry, sceneRegistry,
useRegistry,
useScene, useScene,
useSpatialQuery, useSpatialQuery,
WallEvent, type WallEvent,
WallNode, type WallNode,
} from "@pascal-app/core"; } from '@pascal-app/core'
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from '@pascal-app/viewer'
import { useFrame } from "@react-three/fiber"; import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from "react"; import { useEffect, useRef } from 'react'
import { BoxGeometry, Mesh, MeshStandardMaterial, Vector3 } from "three"; import { BoxGeometry, type Mesh, type MeshStandardMaterial, Vector3 } from 'three'
import { resolveLevelId } from "../../../../../packages/core/src/hooks/spatial-grid/spatial-grid-sync"; import useEditor from '@/store/use-editor'
import { resolveLevelId } from '../../../../../packages/core/src/hooks/spatial-grid/spatial-grid-sync'
/** /**
* Snaps a position to 0.5 grid, with an offset to align item edges to grid lines. * Snaps a position to 0.5 grid, with an offset to align item edges to grid lines.
@@ -23,45 +22,45 @@ import { resolveLevelId } from "../../../../../packages/core/src/hooks/spatial-g
*/ */
function snapToGrid(position: number, dimension: number): number { function snapToGrid(position: number, dimension: number): number {
// Check if half the dimension has a 0.25 remainder (odd multiple of 0.5) // Check if half the dimension has a 0.25 remainder (odd multiple of 0.5)
const halfDim = dimension / 2; const halfDim = dimension / 2
const needsOffset = Math.abs((halfDim * 2) % 1 - 0.5) < 0.01; const needsOffset = Math.abs(((halfDim * 2) % 1) - 0.5) < 0.01
const offset = needsOffset ? 0.25 : 0; const offset = needsOffset ? 0.25 : 0
// Snap to 0.5 grid with offset // Snap to 0.5 grid with offset
return Math.round((position - offset) * 2) / 2 + offset; return Math.round((position - offset) * 2) / 2 + offset
} }
export const ItemTool: React.FC = () => { export const ItemTool: React.FC = () => {
const cursorRef = useRef<Mesh>(null!); const cursorRef = useRef<Mesh>(null!)
const draftItem = useRef<ItemNode | null>(null); const draftItem = useRef<ItemNode | null>(null)
const gridPosition = useRef(new Vector3(0, 0, 0)); const gridPosition = useRef(new Vector3(0, 0, 0))
const selectedItem = useEditor((state) => state.selectedItem); const selectedItem = useEditor((state) => state.selectedItem)
const { canPlaceOnFloor, canPlaceOnWall } = useSpatialQuery(); const { canPlaceOnFloor, canPlaceOnWall } = useSpatialQuery()
const isOnWall = useRef(false); const isOnWall = useRef(false)
useEffect(() => { useEffect(() => {
if (!selectedItem) { if (!selectedItem) {
return; return
} }
let currentWallId: string | null = null; let currentWallId: string | null = null
const checkCanPlace = () => { const checkCanPlace = () => {
const currentLevelId = useViewer.getState().selection.levelId; const currentLevelId = useViewer.getState().selection.levelId
if (currentLevelId && draftItem.current) { if (currentLevelId && draftItem.current) {
let placeable = true; let placeable = true
if (draftItem.current.asset.attachTo) { if (draftItem.current.asset.attachTo) {
if (!isOnWall.current || !currentWallId) { if (!isOnWall.current || !currentWallId) {
placeable = false; placeable = false
} else { } else {
const result = canPlaceOnWall( const result = canPlaceOnWall(
currentLevelId, currentLevelId,
currentWallId as WallNode["id"], currentWallId as WallNode['id'],
gridPosition.current.x, gridPosition.current.x,
gridPosition.current.y, gridPosition.current.y,
draftItem.current.asset.dimensions, draftItem.current.asset.dimensions,
[draftItem.current.id], [draftItem.current.id],
); )
placeable = result.valid; placeable = result.valid
} }
} else { } else {
placeable = canPlaceOnFloor( placeable = canPlaceOnFloor(
@@ -70,235 +69,213 @@ export const ItemTool: React.FC = () => {
draftItem.current.asset.dimensions, draftItem.current.asset.dimensions,
[0, 0, 0], [0, 0, 0],
[draftItem.current.id], [draftItem.current.id],
).valid; ).valid
} }
if (placeable) { if (placeable) {
(cursorRef.current.material as MeshStandardMaterial).color.set( ;(cursorRef.current.material as MeshStandardMaterial).color.set('green')
"green", return true
);
return true;
} else { } else {
(cursorRef.current.material as MeshStandardMaterial).color.set("red"); ;(cursorRef.current.material as MeshStandardMaterial).color.set('red')
return false; return false
} }
} }
}; }
const createDraftItem = () => { const createDraftItem = () => {
const currentLevelId = useViewer.getState().selection.levelId; const currentLevelId = useViewer.getState().selection.levelId
if (!currentLevelId) { if (!currentLevelId) {
return null; return null
} }
useScene.temporal.getState().pause(); useScene.temporal.getState().pause()
draftItem.current = ItemNode.parse({ draftItem.current = ItemNode.parse({
position: [ position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
gridPosition.current.x,
gridPosition.current.y,
gridPosition.current.z,
],
name: selectedItem.name, name: selectedItem.name,
asset: selectedItem, asset: selectedItem,
}); })
useScene.getState().createNode(draftItem.current, currentLevelId); useScene.getState().createNode(draftItem.current, currentLevelId)
checkCanPlace(); checkCanPlace()
}; }
createDraftItem(); createDraftItem()
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return; if (!cursorRef.current) return
if (isOnWall.current) return; if (isOnWall.current) return
const [dimX, , dimZ] = selectedItem.dimensions; const [dimX, , dimZ] = selectedItem.dimensions
gridPosition.current.set( gridPosition.current.set(
snapToGrid(event.position[0], dimX), snapToGrid(event.position[0], dimX),
0, 0,
snapToGrid(event.position[2], dimZ), snapToGrid(event.position[2], dimZ),
); )
cursorRef.current.position.set( cursorRef.current.position.set(
gridPosition.current.x, gridPosition.current.x,
event.position[1], event.position[1],
gridPosition.current.z, gridPosition.current.z,
); )
checkCanPlace(); checkCanPlace()
if (draftItem.current) { if (draftItem.current) {
draftItem.current.position = [ draftItem.current.position = [gridPosition.current.x, 0, gridPosition.current.z]
gridPosition.current.x,
0,
gridPosition.current.z,
];
} }
}; }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
const currentLevelId = useViewer.getState().selection.levelId; const currentLevelId = useViewer.getState().selection.levelId
if (isOnWall.current) return; if (isOnWall.current) return
if (!currentLevelId || !draftItem.current || !checkCanPlace()) return; if (!currentLevelId || !draftItem.current || !checkCanPlace()) return
useScene.temporal.getState().resume(); useScene.temporal.getState().resume()
useScene.getState().updateNode(draftItem.current.id, { useScene.getState().updateNode(draftItem.current.id, {
position: [gridPosition.current.x, 0, gridPosition.current.z], position: [gridPosition.current.x, 0, gridPosition.current.z],
}); })
draftItem.current = null; draftItem.current = null
useScene.temporal.getState().pause(); useScene.temporal.getState().pause()
createDraftItem(); createDraftItem()
}; }
const onWallEnter = (event: WallEvent) => { const onWallEnter = (event: WallEvent) => {
if ( if (
useViewer.getState().selection.levelId !== useViewer.getState().selection.levelId !==
resolveLevelId(event.node, useScene.getState().nodes) resolveLevelId(event.node, useScene.getState().nodes)
) { ) {
return; return
} }
if ( if (
draftItem.current?.asset.attachTo === "wall" || draftItem.current?.asset.attachTo === 'wall' ||
draftItem.current?.asset.attachTo === "wall-side" draftItem.current?.asset.attachTo === 'wall-side'
) { ) {
event.stopPropagation(); event.stopPropagation()
isOnWall.current = true; isOnWall.current = true
currentWallId = event.node.id; currentWallId = event.node.id
gridPosition.current.set( gridPosition.current.set(
Math.round(event.localPosition[0] * 2) / 2, Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[1] * 2) / 2, Math.round(event.localPosition[1] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2, Math.round(event.localPosition[2] * 2) / 2,
); )
draftItem.current.parentId = event.node.id; draftItem.current.parentId = event.node.id
useScene.getState().updateNode(draftItem.current.id, { useScene.getState().updateNode(draftItem.current.id, {
position: [ position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
gridPosition.current.x,
gridPosition.current.y,
gridPosition.current.z,
],
parentId: event.node.id, parentId: event.node.id,
}); })
checkCanPlace(); checkCanPlace()
} }
}; }
const onWallLeave = (event: WallEvent) => { const onWallLeave = (event: WallEvent) => {
if (!isOnWall.current) return; if (!isOnWall.current) return
isOnWall.current = false; isOnWall.current = false
currentWallId = null; currentWallId = null
event.stopPropagation(); event.stopPropagation()
if (!draftItem.current) return; if (!draftItem.current) return
const currentLevelId = useViewer.getState().selection.levelId; const currentLevelId = useViewer.getState().selection.levelId
draftItem.current.parentId = currentLevelId; draftItem.current.parentId = currentLevelId
useScene.getState().updateNode(draftItem.current.id, { useScene.getState().updateNode(draftItem.current.id, {
position: [ position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
gridPosition.current.x,
gridPosition.current.y,
gridPosition.current.z,
],
parentId: currentLevelId, parentId: currentLevelId,
}); })
checkCanPlace(); checkCanPlace()
}; }
const onWallClick = (event: WallEvent) => { const onWallClick = (event: WallEvent) => {
event.stopPropagation(); event.stopPropagation()
if (!isOnWall.current) return; if (!isOnWall.current) return
const currentLevelId = useViewer.getState().selection.levelId; const currentLevelId = useViewer.getState().selection.levelId
if (!currentLevelId || !draftItem.current || !checkCanPlace()) return; if (!currentLevelId || !draftItem.current || !checkCanPlace()) return
useScene.temporal.getState().resume(); useScene.temporal.getState().resume()
useScene.getState().updateNode(draftItem.current.id, { useScene.getState().updateNode(draftItem.current.id, {
position: [ position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
gridPosition.current.x,
gridPosition.current.y,
gridPosition.current.z,
],
parentId: event.node.id, parentId: event.node.id,
}); })
useScene.getState().dirtyNodes.add(event.node.id); useScene.getState().dirtyNodes.add(event.node.id)
draftItem.current = null; draftItem.current = null
useScene.temporal.getState().pause(); useScene.temporal.getState().pause()
createDraftItem(); createDraftItem()
checkCanPlace(); checkCanPlace()
}; }
const onWallMove = (event: WallEvent) => { const onWallMove = (event: WallEvent) => {
if (isOnWall.current === false) return; if (isOnWall.current === false) return
event.stopPropagation(); event.stopPropagation()
if (!draftItem.current) return; if (!draftItem.current) return
gridPosition.current.set( gridPosition.current.set(
Math.round(event.localPosition[0] * 2) / 2, Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[1] * 2) / 2, Math.round(event.localPosition[1] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2, Math.round(event.localPosition[2] * 2) / 2,
); )
cursorRef.current.position.set( cursorRef.current.position.set(
Math.round(event.position[0] * 2) / 2, Math.round(event.position[0] * 2) / 2,
Math.round(event.position[1] * 2) / 2, Math.round(event.position[1] * 2) / 2,
Math.round(event.position[2] * 2) / 2, Math.round(event.position[2] * 2) / 2,
); )
const { const {
node: { start, end }, node: { start, end },
} = event; } = event
const dx = end[0] - start[0]; const dx = end[0] - start[0]
const dz = end[1] - start[1]; const dz = end[1] - start[1]
const { normal } = event; const { normal } = event
const wallAngle = Math.atan2(dx, dz); const wallAngle = Math.atan2(dx, dz)
cursorRef.current.rotation.y = wallAngle + Math.PI / 2; cursorRef.current.rotation.y = wallAngle + Math.PI / 2
const canPlace = checkCanPlace(); const canPlace = checkCanPlace()
if (draftItem.current && canPlace) { if (draftItem.current && canPlace) {
draftItem.current.position = [ draftItem.current.position = [
gridPosition.current.x, gridPosition.current.x,
gridPosition.current.y, gridPosition.current.y,
gridPosition.current.z, gridPosition.current.z,
]; ]
const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id); const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id)
if (draftItemMesh) { if (draftItemMesh) {
draftItemMesh.position.copy(gridPosition.current); draftItemMesh.position.copy(gridPosition.current)
} }
useScene.getState().dirtyNodes.add(event.node.id); useScene.getState().dirtyNodes.add(event.node.id)
} }
}; }
emitter.on("grid:move", onGridMove); emitter.on('grid:move', onGridMove)
emitter.on("grid:click", onGridClick); emitter.on('grid:click', onGridClick)
emitter.on("wall:enter", onWallEnter); emitter.on('wall:enter', onWallEnter)
emitter.on("wall:move", onWallMove); emitter.on('wall:move', onWallMove)
emitter.on("wall:click", onWallClick); emitter.on('wall:click', onWallClick)
emitter.on("wall:leave", onWallLeave); emitter.on('wall:leave', onWallLeave)
const setupBoundingBox = () => { const setupBoundingBox = () => {
const boxGeometry = new BoxGeometry( const boxGeometry = new BoxGeometry(
selectedItem.dimensions[0], selectedItem.dimensions[0],
selectedItem.dimensions[1], selectedItem.dimensions[1],
selectedItem.dimensions[2], selectedItem.dimensions[2],
); )
boxGeometry.translate(0, selectedItem.dimensions[1] / 2, 0); boxGeometry.translate(0, selectedItem.dimensions[1] / 2, 0)
cursorRef.current.geometry = boxGeometry; cursorRef.current.geometry = boxGeometry
}; }
setupBoundingBox(); setupBoundingBox()
return () => { return () => {
if (draftItem.current) { if (draftItem.current) {
useScene.getState().deleteNode(draftItem.current.id); useScene.getState().deleteNode(draftItem.current.id)
} }
useScene.temporal.getState().resume(); useScene.temporal.getState().resume()
emitter.off("grid:move", onGridMove); emitter.off('grid:move', onGridMove)
emitter.off("grid:click", onGridClick); emitter.off('grid:click', onGridClick)
emitter.off("wall:enter", onWallEnter); emitter.off('wall:enter', onWallEnter)
emitter.off("wall:leave", onWallLeave); emitter.off('wall:leave', onWallLeave)
emitter.off("wall:click", onWallClick); emitter.off('wall:click', onWallClick)
emitter.off("wall:move", onWallMove); emitter.off('wall:move', onWallMove)
}; }
}, [selectedItem]); }, [selectedItem, canPlaceOnFloor, canPlaceOnWall])
useFrame((_, delta) => { useFrame((_, delta) => {
if (draftItem.current && !isOnWall.current) { if (draftItem.current && !isOnWall.current) {
const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id); const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id)
if (draftItemMesh) { if (draftItemMesh) {
draftItemMesh.position.lerp(gridPosition.current, delta * 20); draftItemMesh.position.lerp(gridPosition.current, delta * 20)
} }
} }
}); })
return ( return (
<group> <group>
@@ -307,5 +284,5 @@ export const ItemTool: React.FC = () => {
<meshStandardMaterial color="red" wireframe /> <meshStandardMaterial color="red" wireframe />
</mesh> </mesh>
</group> </group>
); )
}; }
+13 -15
View File
@@ -1,28 +1,26 @@
import useEditor, { Phase, Tool } from "@/store/use-editor"; import useEditor, { type Phase, type Tool } from '@/store/use-editor'
import { WallTool } from "./wall/wall-tool"; import { ItemTool } from './item/item-tool'
import { ItemTool } from "./item/item-tool"; import { WallTool } from './wall/wall-tool'
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = { const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
site: { site: {},
},
structure: { structure: {
wall: WallTool, wall: WallTool,
item: ItemTool, item: ItemTool,
}, },
furnish: { furnish: {
item: ItemTool item: ItemTool,
}, },
}; }
export const ToolManager: React.FC = () => { export const ToolManager: React.FC = () => {
const phase = useEditor((state) => state.phase); const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode); const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool); const tool = useEditor((state) => state.tool)
if (mode !== "build" || tool === null) return null; if (mode !== 'build' || tool === null) return null
const Component = tools[phase]?.[tool]
const Component = tools[phase]?.[tool]; return Component ? <Component /> : null
}
return Component ? <Component /> : null;
};
+40 -58
View File
@@ -1,75 +1,62 @@
import { emitter, GridEvent, useScene, WallNode } from "@pascal-app/core"; import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core'
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from "react"; import { useEffect, useRef } from 'react'
import { Line, Mesh, Vector3 } from "three"; import { type Line, type Mesh, Vector3 } from 'three'
const commitWallDrawing = (start: [number, number], end: [number, number]) => { const commitWallDrawing = (start: [number, number], end: [number, number]) => {
const currentLevelId = useViewer.getState().selection.levelId; const currentLevelId = useViewer.getState().selection.levelId
const { createNode } = useScene.getState(); const { createNode } = useScene.getState()
if (!currentLevelId) return; if (!currentLevelId) return
const wall = WallNode.parse({ start, end }); const wall = WallNode.parse({ start, end })
createNode(wall, currentLevelId); createNode(wall, currentLevelId)
}; }
export const WallTool: React.FC = () => { export const WallTool: React.FC = () => {
const cursorRef = useRef<Mesh>(null); const cursorRef = useRef<Mesh>(null)
const drawingLineRef = useRef<Line>(null!); const drawingLineRef = useRef<Line>(null!)
useEffect(() => { useEffect(() => {
let buildingState = 0; let buildingState = 0
const startingPoint = new Vector3(0, 0, 0); const startingPoint = new Vector3(0, 0, 0)
const endingPoint = new Vector3(0, 0, 0); const endingPoint = new Vector3(0, 0, 0)
let gridPosition: [number, number] = [0, 0]; let gridPosition: [number, number] = [0, 0]
drawingLineRef.current.geometry.setFromPoints([startingPoint, endingPoint]); drawingLineRef.current.geometry.setFromPoints([startingPoint, endingPoint])
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return; if (!cursorRef.current) return
gridPosition = [ gridPosition = [Math.round(event.position[0] * 2) / 2, Math.round(event.position[2] * 2) / 2]
Math.round(event.position[0] * 2) / 2, cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1])
Math.round(event.position[2] * 2) / 2,
];
cursorRef.current.position.set(
gridPosition[0],
event.position[1],
gridPosition[1],
);
if (buildingState === 1) { if (buildingState === 1) {
endingPoint.set(gridPosition[0], event.position[1], gridPosition[1]); endingPoint.set(gridPosition[0], event.position[1], gridPosition[1])
} }
drawingLineRef.current.geometry.setFromPoints([ drawingLineRef.current.geometry.setFromPoints([startingPoint, endingPoint])
startingPoint, }
endingPoint,
]);
};
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (buildingState === 0) { if (buildingState === 0) {
startingPoint.set(gridPosition[0], event.position[1], gridPosition[1]); startingPoint.set(gridPosition[0], event.position[1], gridPosition[1])
buildingState = 1; buildingState = 1
console.log("starting building at:", startingPoint); console.log('starting building at:', startingPoint)
drawingLineRef.current.visible = true; drawingLineRef.current.visible = true
} else if (buildingState === 1) { } else if (buildingState === 1) {
commitWallDrawing( commitWallDrawing([startingPoint.x, startingPoint.z], [endingPoint.x, endingPoint.z])
[startingPoint.x, startingPoint.z], drawingLineRef.current.visible = false
[endingPoint.x, endingPoint.z], buildingState = 0
);
drawingLineRef.current.visible = false;
buildingState = 0;
} }
}; }
emitter.on("grid:move", onGridMove); emitter.on('grid:move', onGridMove)
emitter.on("grid:click", onGridClick); emitter.on('grid:click', onGridClick)
return () => { return () => {
emitter.off("grid:move", onGridMove); emitter.off('grid:move', onGridMove)
emitter.off("grid:click", onGridClick); emitter.off('grid:click', onGridClick)
}; }
}, []); }, [])
return ( return (
<group> <group>
@@ -79,12 +66,7 @@ export const WallTool: React.FC = () => {
</mesh> </mesh>
<group> <group>
{/* @ts-ignore */} {/* @ts-ignore */}
<line <line ref={drawingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
ref={drawingLineRef}
frustumCulled={false}
renderOrder={1}
visible={false}
>
<bufferGeometry /> <bufferGeometry />
<lineDashedNodeMaterial <lineDashedNodeMaterial
color="blue" color="blue"
@@ -98,5 +80,5 @@ export const WallTool: React.FC = () => {
</line> </line>
</group> </group>
</group> </group>
); )
}; }
+11 -13
View File
@@ -1,21 +1,19 @@
import * as React from "react"; import * as React from 'react'
const MOBILE_BREAKPOINT = 768; const MOBILE_BREAKPOINT = 768
export function useIsMobile() { export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>( const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
undefined,
);
React.useEffect(() => { React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => { const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}; }
mql.addEventListener("change", onChange); mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange); return () => mql.removeEventListener('change', onChange)
}, []); }, [])
return !!isMobile; return !!isMobile
} }
+3 -3
View File
@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"; import { type ClassValue, clsx } from 'clsx'
import { twMerge } from "tailwind-merge"; import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs))
} }
+3 -3
View File
@@ -1,6 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
transpilePackages: ["three", "@pascal-app/viewer", "@pascal-app/core"], transpilePackages: ['three', '@pascal-app/viewer', '@pascal-app/core'],
}; }
export default nextConfig; export default nextConfig
+65 -70
View File
@@ -1,111 +1,106 @@
"use client"; 'use client'
import { import { type BuildingNode, type LevelNode, useScene } from '@pascal-app/core'
AssetInput, import { useViewer } from '@pascal-app/viewer'
BuildingNode, import { create } from 'zustand'
LevelNode, import type { Asset } from '../../../packages/core/src/schema/nodes/item'
useScene,
} 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"; export type Phase = 'site' | 'structure' | 'furnish'
export type Mode = "select" | "edit" | "delete" | "build"; export type Mode = 'select' | 'edit' | 'delete' | 'build'
// Structure mode tools (building elements) // Structure mode tools (building elements)
export type StructureTool = export type StructureTool =
| "wall" | 'wall'
| "room" | 'room'
| "custom-room" | 'custom-room'
| "slab" | 'slab'
| "ceiling" | 'ceiling'
| "roof" | 'roof'
| "column" | 'column'
| "stair" | 'stair'
| "item" | 'item'
| "zone"; | 'zone'
// Furnish mode tools (items and decoration) // Furnish mode tools (items and decoration)
export type FurnishTool = "item"; export type FurnishTool = 'item'
// Site mode tools // Site mode tools
export type SiteTool = "property-line"; export type SiteTool = 'property-line'
// Catalog categories for furnish mode items // Catalog categories for furnish mode items
export type CatalogCategory = export type CatalogCategory =
| "furniture" | 'furniture'
| "appliance" | 'appliance'
| "bathroom" | 'bathroom'
| "kitchen" | 'kitchen'
| "outdoor" | 'outdoor'
| "window" | 'window'
| "door"; | 'door'
// Combined tool type // Combined tool type
export type Tool = SiteTool | StructureTool | FurnishTool; export type Tool = SiteTool | StructureTool | FurnishTool
type EditorState = { type EditorState = {
phase: Phase; phase: Phase
setPhase: (phase: Phase) => void; setPhase: (phase: Phase) => void
mode: Mode; mode: Mode
setMode: (mode: Mode) => void; setMode: (mode: Mode) => void
tool: Tool | null; tool: Tool | null
setTool: (tool: Tool | null) => void; setTool: (tool: Tool | null) => void
catalogCategory: CatalogCategory | null; catalogCategory: CatalogCategory | null
setCatalogCategory: (category: CatalogCategory | null) => void; setCatalogCategory: (category: CatalogCategory | null) => void
selectedItem: Asset | null; selectedItem: Asset | null
setSelectedItem: (item: Asset) => void; setSelectedItem: (item: Asset) => void
}; }
const useEditor = create<EditorState>()((set, get) => ({ const useEditor = create<EditorState>()((set, get) => ({
phase: "site", phase: 'site',
setPhase: (phase) => { setPhase: (phase) => {
const currentPhase = get().phase; const currentPhase = get().phase
if (currentPhase === phase) return; if (currentPhase === phase) return
set({ phase }); set({ phase })
const viewer = useViewer.getState(); const viewer = useViewer.getState()
const scene = useScene.getState(); const scene = useScene.getState()
switch (phase) { switch (phase) {
case "site": case 'site':
// In Site mode, we zoom out and deselect specific levels/buildings // In Site mode, we zoom out and deselect specific levels/buildings
viewer.resetSelection(); viewer.resetSelection()
viewer.setLevelMode("stacked"); viewer.setLevelMode('stacked')
break; break
case "structure": case 'structure':
// In Structure mode, we often want to focus on a specific building/level // In Structure mode, we often want to focus on a specific building/level
// Auto-select the first building if none is selected // Auto-select the first building if none is selected
if (!viewer.selection.buildingId) { if (!viewer.selection.buildingId) {
const firstBuildingId = scene.rootNodeIds.find((id) => { const firstBuildingId = scene.rootNodeIds.find((id) => {
const node = scene.nodes[id]; const node = scene.nodes[id]
return node?.type === "building" || null; return node?.type === 'building' || null
}); })
if (firstBuildingId) { if (firstBuildingId) {
viewer.setSelection({ viewer.setSelection({
buildingId: firstBuildingId as BuildingNode["id"], buildingId: firstBuildingId as BuildingNode['id'],
}); })
const buildingNode = scene.nodes[firstBuildingId] as BuildingNode; const buildingNode = scene.nodes[firstBuildingId] as BuildingNode
const firstLevelId = buildingNode.children[0]; const firstLevelId = buildingNode.children[0]
if (firstLevelId) { if (firstLevelId) {
viewer.setSelection({ levelId: firstLevelId as LevelNode["id"] }); viewer.setSelection({ levelId: firstLevelId as LevelNode['id'] })
} }
} }
} }
viewer.setLevelMode("stacked"); // Better for structure editing viewer.setLevelMode('stacked') // Better for structure editing
break; break
case "furnish": case 'furnish':
// Maybe in furnish mode we force "solo" level view to see inside rooms // Maybe in furnish mode we force "solo" level view to see inside rooms
viewer.setLevelMode("solo"); viewer.setLevelMode('solo')
break; break
} }
}, },
mode: "select", mode: 'select',
setMode: (mode) => set({ mode }), setMode: (mode) => set({ mode }),
tool: null, tool: null,
setTool: (tool) => set({ tool }), setTool: (tool) => set({ tool }),
@@ -113,6 +108,6 @@ const useEditor = create<EditorState>()((set, get) => ({
setCatalogCategory: (category) => set({ catalogCategory: category }), setCatalogCategory: (category) => set({ catalogCategory: category }),
selectedItem: null, selectedItem: null,
setSelectedItem: (item) => set({ selectedItem: item }), setSelectedItem: (item) => set({ selectedItem: item }),
})); }))
export default useEditor; export default useEditor
+5 -2
View File
@@ -1,6 +1,5 @@
{ {
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["ultracite", "ultracite/core", "ultracite/next"],
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",
@@ -66,7 +65,7 @@
"correctness": { "correctness": {
"noUnusedVariables": "off", "noUnusedVariables": "off",
"noUnusedFunctionParameters": "off", "noUnusedFunctionParameters": "off",
"noUnusedImports": "off", "noUnusedImports": "error",
"useExhaustiveDependencies": "info", "useExhaustiveDependencies": "info",
"noPrecisionLoss": "off" "noPrecisionLoss": "off"
}, },
@@ -98,6 +97,10 @@
"packages/**/*.css", "packages/**/*.css",
"packages/**/*.md", "packages/**/*.md",
"packages/**/*.mdx", "packages/**/*.mdx",
"apps/**/*.ts",
"apps/**/*.tsx",
"apps/**/*.js",
"apps/**/*.jsx",
"!**/node_modules", "!**/node_modules",
"!**/.next", "!**/.next",
"!**/dist", "!**/dist",
+30 -30
View File
@@ -1,49 +1,49 @@
import mitt from "mitt"; import mitt from 'mitt'
import { BuildingNode, ItemNode, WallNode } from "../schema"; import type { BuildingNode, ItemNode, WallNode } from '../schema'
import { AnyNode } from "../schema/types"; import type { AnyNode } from '../schema/types'
// Base event interfaces // Base event interfaces
export interface GridEvent { export interface GridEvent {
position: [number, number, number]; position: [number, number, number]
} }
export interface NodeEvent<T extends AnyNode = AnyNode> { export interface NodeEvent<T extends AnyNode = AnyNode> {
node: T; node: T
position: [number, number, number]; position: [number, number, number]
localPosition: [number, number, number]; localPosition: [number, number, number]
normal?: [number, number, number]; normal?: [number, number, number]
stopPropagation: () => void; stopPropagation: () => void
} }
export type WallEvent = NodeEvent<WallNode>; export type WallEvent = NodeEvent<WallNode>
export type ItemEvent = NodeEvent<ItemNode>; export type ItemEvent = NodeEvent<ItemNode>
export type BuildingEvent = NodeEvent<BuildingNode>; export type BuildingEvent = NodeEvent<BuildingNode>
// Event suffixes - exported for use in hooks // Event suffixes - exported for use in hooks
export const eventSuffixes = [ export const eventSuffixes = [
"click", 'click',
"move", 'move',
"enter", 'enter',
"leave", 'leave',
"pointerdown", 'pointerdown',
"pointerup", 'pointerup',
"context-menu", 'context-menu',
"double-click", 'double-click',
] as const; ] as const
export type EventSuffix = (typeof eventSuffixes)[number]; export type EventSuffix = (typeof eventSuffixes)[number]
type NodeEvents<T extends string, E> = { type NodeEvents<T extends string, E> = {
[K in `${T}:${EventSuffix}`]: E; [K in `${T}:${EventSuffix}`]: E
}; }
type GridEvents = { type GridEvents = {
[K in `grid:${EventSuffix}`]: GridEvent; [K in `grid:${EventSuffix}`]: GridEvent
}; }
type EditorEvents = GridEvents & type EditorEvents = GridEvents &
NodeEvents<"wall", WallEvent> & NodeEvents<'wall', WallEvent> &
NodeEvents<"item", ItemEvent> & NodeEvents<'item', ItemEvent> &
NodeEvents<"building", BuildingEvent>; 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 type * as THREE from 'three'
import { useLayoutEffect } from "react";
export const sceneRegistry = { export const sceneRegistry = {
// Master lookup: ID -> Object3D // Master lookup: ID -> Object3D
@@ -15,27 +14,27 @@ export const sceneRegistry = {
item: new Set<string>(), item: new Set<string>(),
slab: new Set<string>(), slab: new Set<string>(),
}, },
}; }
export function useRegistry( export function useRegistry(
id: string, id: string,
type: keyof typeof sceneRegistry.byType, type: keyof typeof sceneRegistry.byType,
ref: React.RefObject<THREE.Object3D> ref: React.RefObject<THREE.Object3D>,
) { ) {
useLayoutEffect(() => { useLayoutEffect(() => {
const obj = ref.current; const obj = ref.current
if (!obj) return; if (!obj) return
// 1. Add to master map // 1. Add to master map
sceneRegistry.nodes.set(id, obj); sceneRegistry.nodes.set(id, obj)
// 2. Add to type-specific set // 2. Add to type-specific set
sceneRegistry.byType[type].add(id); sceneRegistry.byType[type].add(id)
// 4. Cleanup when component unmounts // 4. Cleanup when component unmounts
return () => { return () => {
sceneRegistry.nodes.delete(id); sceneRegistry.nodes.delete(id)
sceneRegistry.byType[type].delete(id); sceneRegistry.byType[type].delete(id)
}; }
}, [id, type, ref]); }, [id, type, ref])
} }
@@ -1,64 +1,58 @@
import { AnyNode, ItemNode, WallNode } from "../../schema"; import type { AnyNode, ItemNode, WallNode } from '../../schema'
import { SpatialGrid } from "./spatial-grid"; import { SpatialGrid } from './spatial-grid'
import { WallSpatialGrid } from "./wall-spatial-grid"; import { WallSpatialGrid } from './wall-spatial-grid'
export class SpatialGridManager { export class SpatialGridManager {
private floorGrids = new Map<string, SpatialGrid>(); // levelId -> grid private floorGrids = new Map<string, SpatialGrid>() // levelId -> grid
private wallGrids = new Map<string, WallSpatialGrid>(); // levelId -> wall grid private wallGrids = new Map<string, WallSpatialGrid>() // levelId -> wall grid
private walls = new Map<string, WallNode>(); // wallId -> wall data (for length calculations) private walls = new Map<string, WallNode>() // wallId -> wall data (for length calculations)
constructor(private cellSize = 0.5) {} constructor(private cellSize = 0.5) {}
private getFloorGrid(levelId: string): SpatialGrid { private getFloorGrid(levelId: string): SpatialGrid {
if (!this.floorGrids.has(levelId)) { if (!this.floorGrids.has(levelId)) {
this.floorGrids.set( this.floorGrids.set(levelId, new SpatialGrid({ cellSize: this.cellSize }))
levelId,
new SpatialGrid({ cellSize: this.cellSize }),
);
} }
return this.floorGrids.get(levelId)!; return this.floorGrids.get(levelId)!
} }
private getWallGrid(levelId: string): WallSpatialGrid { private getWallGrid(levelId: string): WallSpatialGrid {
if (!this.wallGrids.has(levelId)) { 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 { private getWallLength(wallId: string): number {
const wall = this.walls.get(wallId); const wall = this.walls.get(wallId)
if (!wall) return 0; if (!wall) return 0
const dx = wall.end[0] - wall.start[0]; const dx = wall.end[0] - wall.start[0]
const dy = wall.end[1] - wall.start[1]; const dy = wall.end[1] - wall.start[1]
return Math.sqrt(dx * dx + dy * dy); return Math.sqrt(dx * dx + dy * dy)
} }
private getWallHeight(wallId: string): number { private getWallHeight(wallId: string): number {
const wall = this.walls.get(wallId); const wall = this.walls.get(wallId)
return wall?.height ?? 2.5; // Default wall height return wall?.height ?? 2.5 // Default wall height
} }
// Called when nodes change // Called when nodes change
handleNodeCreated(node: AnyNode, levelId: string) { handleNodeCreated(node: AnyNode, levelId: string) {
if (node.type === "wall") { if (node.type === 'wall') {
const wall = node as WallNode; const wall = node as WallNode
this.walls.set(wall.id, wall); this.walls.set(wall.id, wall)
} else if (node.type === "item") { } else if (node.type === 'item') {
const item = node as ItemNode; const item = node as ItemNode
if ( if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') {
item.asset.attachTo === "wall" ||
item.asset.attachTo === "wall-side"
) {
// Wall-attached item - use parentId as the wall ID // Wall-attached item - use parentId as the wall ID
const wallId = item.parentId; const wallId = item.parentId
if (wallId && this.walls.has(wallId)) { if (wallId && this.walls.has(wallId)) {
const wallLength = this.getWallLength(wallId); const wallLength = this.getWallLength(wallId)
if (wallLength > 0) { if (wallLength > 0) {
const [width, height] = item.asset.dimensions; const [width, height] = item.asset.dimensions
const halfW = width / wallLength / 2; const halfW = width / wallLength / 2
// Calculate t from local X position (position[0] is distance along wall) // 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 // position[1] is the bottom of the item
this.getWallGrid(levelId).insert({ this.getWallGrid(levelId).insert({
itemId: item.id, itemId: item.id,
@@ -67,7 +61,7 @@ export class SpatialGridManager {
tEnd: t + halfW, tEnd: t + halfW,
yStart: item.position[1], yStart: item.position[1],
yEnd: item.position[1] + height, yEnd: item.position[1] + height,
}); })
} }
} }
} else if (!item.asset.attachTo) { } else if (!item.asset.attachTo) {
@@ -77,31 +71,28 @@ export class SpatialGridManager {
item.position, item.position,
item.asset.dimensions, item.asset.dimensions,
item.rotation, item.rotation,
); )
} }
} }
} }
handleNodeUpdated(node: AnyNode, levelId: string) { handleNodeUpdated(node: AnyNode, levelId: string) {
if (node.type === "wall") { if (node.type === 'wall') {
const wall = node as WallNode; const wall = node as WallNode
this.walls.set(wall.id, wall); this.walls.set(wall.id, wall)
} else if (node.type === "item") { } else if (node.type === 'item') {
const item = node as ItemNode; const item = node as ItemNode
if ( if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') {
item.asset.attachTo === "wall" ||
item.asset.attachTo === "wall-side"
) {
// Remove old placement and re-insert // Remove old placement and re-insert
this.getWallGrid(levelId).removeByItemId(item.id); this.getWallGrid(levelId).removeByItemId(item.id)
const wallId = item.parentId; const wallId = item.parentId
if (wallId && this.walls.has(wallId)) { if (wallId && this.walls.has(wallId)) {
const wallLength = this.getWallLength(wallId); const wallLength = this.getWallLength(wallId)
if (wallLength > 0) { if (wallLength > 0) {
const [width, height] = item.asset.dimensions; const [width, height] = item.asset.dimensions
const halfW = width / wallLength / 2; const halfW = width / wallLength / 2
// Calculate t from local X position (position[0] is distance along wall) // 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 // position[1] is the bottom of the item
this.getWallGrid(levelId).insert({ this.getWallGrid(levelId).insert({
itemId: item.id, itemId: item.id,
@@ -110,7 +101,7 @@ export class SpatialGridManager {
tEnd: t + halfW, tEnd: t + halfW,
yStart: item.position[1], yStart: item.position[1],
yEnd: item.position[1] + height, yEnd: item.position[1] + height,
}); })
} }
} }
} else if (!item.asset.attachTo) { } else if (!item.asset.attachTo) {
@@ -119,22 +110,22 @@ export class SpatialGridManager {
item.position, item.position,
item.asset.dimensions, item.asset.dimensions,
item.rotation, item.rotation,
); )
} }
} }
} }
handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) { handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) {
if (nodeType === "wall") { if (nodeType === 'wall') {
this.walls.delete(nodeId); this.walls.delete(nodeId)
// Remove all items attached to this wall from the spatial grid // Remove all items attached to this wall from the spatial grid
const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId); const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId)
return removedItemIds; // Caller can use this to delete the items from scene return removedItemIds // Caller can use this to delete the items from scene
} else if (nodeType === "item") { } else if (nodeType === 'item') {
this.getFloorGrid(levelId).remove(nodeId); this.getFloorGrid(levelId).remove(nodeId)
this.getWallGrid(levelId).removeByItemId(nodeId); this.getWallGrid(levelId).removeByItemId(nodeId)
} }
return []; return []
} }
// Query methods // Query methods
@@ -145,8 +136,8 @@ export class SpatialGridManager {
rotation: [number, number, number], rotation: [number, number, number],
ignoreIds?: string[], ignoreIds?: string[],
) { ) {
const grid = this.getFloorGrid(levelId); const grid = this.getFloorGrid(levelId)
return grid.canPlace(position, dimensions, rotation, ignoreIds); return grid.canPlace(position, dimensions, rotation, ignoreIds)
} }
/** /**
@@ -166,14 +157,14 @@ export class SpatialGridManager {
dimensions: [number, number, number], dimensions: [number, number, number],
ignoreIds?: string[], ignoreIds?: string[],
) { ) {
const wallLength = this.getWallLength(wallId); const wallLength = this.getWallLength(wallId)
if (wallLength === 0) { 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) // Convert local X position to parametric t (0-1)
const tCenter = localX / wallLength; const tCenter = localX / wallLength
const [itemWidth, itemHeight] = dimensions; const [itemWidth, itemHeight] = dimensions
return this.getWallGrid(levelId).canPlaceOnWall( return this.getWallGrid(levelId).canPlaceOnWall(
wallId, wallId,
wallLength, wallLength,
@@ -183,24 +174,24 @@ export class SpatialGridManager {
localY, localY,
itemHeight, itemHeight,
ignoreIds, ignoreIds,
); )
} }
getWallForItem(levelId: string, itemId: string): string | undefined { getWallForItem(levelId: string, itemId: string): string | undefined {
return this.getWallGrid(levelId).getWallForItem(itemId); return this.getWallGrid(levelId).getWallForItem(itemId)
} }
clearLevel(levelId: string) { clearLevel(levelId: string) {
this.floorGrids.delete(levelId); this.floorGrids.delete(levelId)
this.wallGrids.delete(levelId); this.wallGrids.delete(levelId)
} }
clear() { clear() {
this.floorGrids.clear(); this.floorGrids.clear()
this.wallGrids.clear(); this.wallGrids.clear()
this.walls.clear(); this.walls.clear()
} }
} }
// Singleton instance // Singleton instance
export const spatialGridManager = new SpatialGridManager(); export const spatialGridManager = new SpatialGridManager()
@@ -1,70 +1,67 @@
import { AnyNode } from "../../schema"; import type { AnyNode } from '../../schema'
import useScene from "../../store/use-scene"; import useScene from '../../store/use-scene'
import { spatialGridManager } from "./spatial-grid-manager"; import { spatialGridManager } from './spatial-grid-manager'
export function resolveLevelId( export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): string {
node: AnyNode,
nodes: Record<string, AnyNode>,
): string {
// If the node itself is a level // 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 // Walk up parent chain to find level
// This assumes you track parentId or can derive it // This assumes you track parentId or can derive it
let current: AnyNode | undefined = node; let current: AnyNode | undefined = node
while (current) { 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) // Find parent (you might need to add parentId to your schema or derive it)
if (!current.parentId) { if (!current.parentId) {
current = undefined; current = undefined
} else { } 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 // Call this once at app initialization
export function initSpatialGridSync() { export function initSpatialGridSync() {
const store = useScene; const store = useScene
// Subscribe to all changes // Subscribe to all changes
store.subscribe((state, prevState) => { store.subscribe((state, prevState) => {
// Detect added nodes // Detect added nodes
for (const [id, node] of Object.entries(state.nodes)) { for (const [id, node] of Object.entries(state.nodes)) {
if (!prevState.nodes[id as AnyNode["id"]]) { if (!prevState.nodes[id as AnyNode['id']]) {
const levelId = resolveLevelId(node, state.nodes); const levelId = resolveLevelId(node, state.nodes)
spatialGridManager.handleNodeCreated(node, levelId); spatialGridManager.handleNodeCreated(node, levelId)
} }
} }
// Detect removed nodes // Detect removed nodes
for (const [id, node] of Object.entries(prevState.nodes)) { for (const [id, node] of Object.entries(prevState.nodes)) {
if (!state.nodes[id as AnyNode["id"]]) { if (!state.nodes[id as AnyNode['id']]) {
const levelId = resolveLevelId(node, prevState.nodes); const levelId = resolveLevelId(node, prevState.nodes)
spatialGridManager.handleNodeDeleted(id, node.type, levelId); spatialGridManager.handleNodeDeleted(id, node.type, levelId)
} }
} }
// Detect updated nodes (items with position/rotation/parentId changes) // Detect updated nodes (items with position/rotation/parentId changes)
for (const [id, node] of Object.entries(state.nodes)) { for (const [id, node] of Object.entries(state.nodes)) {
const prev = prevState.nodes[id as AnyNode["id"]]; const prev = prevState.nodes[id as AnyNode['id']]
if (prev && node.type === "item" && prev.type === "item") { if (prev && node.type === 'item' && prev.type === 'item') {
if ( if (
!arraysEqual(node.position, prev.position) || !arraysEqual(node.position, prev.position) ||
!arraysEqual(node.rotation, prev.rotation) || !arraysEqual(node.rotation, prev.rotation) ||
node.parentId !== prev.parentId node.parentId !== prev.parentId
) { ) {
const levelId = resolveLevelId(node, state.nodes); const levelId = resolveLevelId(node, state.nodes)
spatialGridManager.handleNodeUpdated(node, levelId); spatialGridManager.handleNodeUpdated(node, levelId)
} }
} }
} }
}); })
} }
function arraysEqual(a: number[], b: number[]): boolean { 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 { interface GridCell {
itemIds: Set<string>; itemIds: Set<string>
} }
interface SpatialGridConfig { 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 { export class SpatialGrid {
private cells = new Map<CellKey, GridCell>(); private cells = new Map<CellKey, GridCell>()
private itemCells = new Map<string, Set<CellKey>>(); // reverse lookup private itemCells = new Map<string, Set<CellKey>>() // reverse lookup
constructor(private config: SpatialGridConfig) {} constructor(private config: SpatialGridConfig) {}
private posToCell(x: number, z: number): [number, number] { private posToCell(x: number, z: number): [number, number] {
return [ return [Math.floor(x / this.config.cellSize), Math.floor(z / this.config.cellSize)]
Math.floor(x / this.config.cellSize),
Math.floor(z / this.config.cellSize),
];
} }
private cellKey(cx: number, cz: number): CellKey { private cellKey(cx: number, cz: number): CellKey {
return `${cx},${cz}`; return `${cx},${cz}`
} }
// Get all cells an item occupies based on its AABB // Get all cells an item occupies based on its AABB
@@ -33,34 +30,34 @@ export class SpatialGrid {
): CellKey[] { ): CellKey[] {
// Simplified: axis-aligned bounding box // Simplified: axis-aligned bounding box
// For full rotation support, compute rotated corners // For full rotation support, compute rotated corners
const [x, , z] = position; const [x, , z] = position
const [w, , d] = dimensions; const [w, , d] = dimensions
const yRot = rotation[1]; // Y-axis rotation const yRot = rotation[1] // Y-axis rotation
// Compute rotated footprint (simplified for 90° increments) // Compute rotated footprint (simplified for 90° increments)
const cos = Math.abs(Math.cos(yRot)); const cos = Math.abs(Math.cos(yRot))
const sin = Math.abs(Math.sin(yRot)); const sin = Math.abs(Math.sin(yRot))
const rotatedW = w * cos + d * sin; const rotatedW = w * cos + d * sin
const rotatedD = w * sin + d * cos; const rotatedD = w * sin + d * cos
const minX = x - rotatedW / 2; const minX = x - rotatedW / 2
const maxX = x + rotatedW / 2; const maxX = x + rotatedW / 2
const minZ = z - rotatedD / 2; const minZ = z - rotatedD / 2
const maxZ = 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 // Use exclusive upper bound: subtract epsilon so exact boundaries don't overlap
// This allows adjacent items (touching but not overlapping) to not conflict // This allows adjacent items (touching but not overlapping) to not conflict
const epsilon = 1e-6; const epsilon = 1e-6
const [maxCx, maxCz] = this.posToCell(maxX - epsilon, maxZ - epsilon); const [maxCx, maxCz] = this.posToCell(maxX - epsilon, maxZ - epsilon)
const keys: CellKey[] = []; const keys: CellKey[] = []
for (let cx = minCx; cx <= maxCx; cx++) { for (let cx = minCx; cx <= maxCx; cx++) {
for (let cz = minCz; cz <= maxCz; cz++) { 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 // Register an item
@@ -70,33 +67,33 @@ export class SpatialGrid {
dimensions: [number, number, number], dimensions: [number, number, number],
rotation: [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) { for (const key of cellKeys) {
if (!this.cells.has(key)) { 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 an item
remove(itemId: string) { remove(itemId: string) {
const cellKeys = this.itemCells.get(itemId); const cellKeys = this.itemCells.get(itemId)
if (!cellKeys) return; if (!cellKeys) return
for (const key of cellKeys) { for (const key of cellKeys) {
const cell = this.cells.get(key); const cell = this.cells.get(key)
if (cell) { if (cell) {
cell.itemIds.delete(itemId); cell.itemIds.delete(itemId)
if (cell.itemIds.size === 0) { if (cell.itemIds.size === 0) {
this.cells.delete(key); this.cells.delete(key)
} }
} }
} }
this.itemCells.delete(itemId); this.itemCells.delete(itemId)
} }
// Update = remove + insert // Update = remove + insert
@@ -106,8 +103,8 @@ export class SpatialGrid {
dimensions: [number, number, number], dimensions: [number, number, number],
rotation: [number, number, number], rotation: [number, number, number],
) { ) {
this.remove(itemId); this.remove(itemId)
this.insert(itemId, position, dimensions, rotation); this.insert(itemId, position, dimensions, rotation)
} }
// Query: is this placement valid? // Query: is this placement valid?
@@ -117,16 +114,16 @@ export class SpatialGrid {
rotation: [number, number, number], rotation: [number, number, number],
ignoreIds: string[] = [], ignoreIds: string[] = [],
): { valid: boolean; conflictIds: string[] } { ): { valid: boolean; conflictIds: string[] } {
const cellKeys = this.getItemCells(position, dimensions, rotation); const cellKeys = this.getItemCells(position, dimensions, rotation)
const ignoreSet = new Set(ignoreIds); const ignoreSet = new Set(ignoreIds)
const conflicts = new Set<string>(); const conflicts = new Set<string>()
for (const key of cellKeys) { for (const key of cellKeys) {
const cell = this.cells.get(key); const cell = this.cells.get(key)
if (cell) { if (cell) {
for (const id of cell.itemIds) { for (const id of cell.itemIds) {
if (!ignoreSet.has(id)) { if (!ignoreSet.has(id)) {
conflicts.add(id); conflicts.add(id)
} }
} }
} }
@@ -135,29 +132,29 @@ export class SpatialGrid {
return { return {
valid: conflicts.size === 0, valid: conflicts.size === 0,
conflictIds: [...conflicts], conflictIds: [...conflicts],
}; }
} }
// Query: get all items near a point (for snapping, selection, etc.) // Query: get all items near a point (for snapping, selection, etc.)
queryRadius(x: number, z: number, radius: number): string[] { queryRadius(x: number, z: number, radius: number): string[] {
const cellRadius = Math.ceil(radius / this.config.cellSize); const cellRadius = Math.ceil(radius / this.config.cellSize)
const [cx, cz] = this.posToCell(x, z); const [cx, cz] = this.posToCell(x, z)
const found = new Set<string>(); const found = new Set<string>()
for (let dx = -cellRadius; dx <= cellRadius; dx++) { for (let dx = -cellRadius; dx <= cellRadius; dx++) {
for (let dz = -cellRadius; dz <= cellRadius; dz++) { 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) { if (cell) {
for (const id of cell.itemIds) { for (const id of cell.itemIds) {
found.add(id); found.add(id)
} }
} }
} }
} }
return [...found]; return [...found]
} }
getItemCount(): number { getItemCount(): number {
return this.itemCells.size; return this.itemCells.size
} }
} }
@@ -1,31 +1,25 @@
import { useCallback } from "react"; import { useCallback } from 'react'
import { LevelNode, WallNode } from "../../schema"; import type { LevelNode, WallNode } from '../../schema'
import { spatialGridManager } from "./spatial-grid-manager"; import { spatialGridManager } from './spatial-grid-manager'
export function useSpatialQuery() { export function useSpatialQuery() {
const canPlaceOnFloor = useCallback( const canPlaceOnFloor = useCallback(
( (
levelId: LevelNode["id"], levelId: LevelNode['id'],
position: [number, number, number], position: [number, number, number],
dimensions: [number, number, number], dimensions: [number, number, number],
rotation: [number, number, number], rotation: [number, number, number],
ignoreIds?: string[], ignoreIds?: string[],
) => { ) => {
return spatialGridManager.canPlaceOnFloor( return spatialGridManager.canPlaceOnFloor(levelId, position, dimensions, rotation, ignoreIds)
levelId,
position,
dimensions,
rotation,
ignoreIds,
);
}, },
[], [],
); )
const canPlaceOnWall = useCallback( const canPlaceOnWall = useCallback(
( (
levelId: LevelNode["id"], levelId: LevelNode['id'],
wallId: WallNode["id"], wallId: WallNode['id'],
localX: number, localX: number,
localY: number, localY: number,
dimensions: [number, number, number], dimensions: [number, number, number],
@@ -38,10 +32,10 @@ export function useSpatialQuery() {
localY, localY,
dimensions, dimensions,
ignoreIds, ignoreIds,
); )
}, },
[], [],
); )
return { canPlaceOnFloor, canPlaceOnWall }; return { canPlaceOnFloor, canPlaceOnWall }
} }
@@ -1,15 +1,15 @@
interface WallItemPlacement { interface WallItemPlacement {
itemId: string; itemId: string
wallId: string; wallId: string
tStart: number; // 0-1 parametric position along wall tStart: number // 0-1 parametric position along wall
tEnd: number; tEnd: number
yStart: number; // height range yStart: number // height range
yEnd: number; yEnd: number
} }
export class WallSpatialGrid { export class WallSpatialGrid {
private wallItems = new Map<string, WallItemPlacement[]>(); // wallId -> placements private wallItems = new Map<string, WallItemPlacement[]>() // wallId -> placements
private itemToWall = new Map<string, string>(); // itemId -> wallId (reverse lookup) private itemToWall = new Map<string, string>() // itemId -> wallId (reverse lookup)
canPlaceOnWall( canPlaceOnWall(
wallId: string, wallId: string,
@@ -21,82 +21,82 @@ export class WallSpatialGrid {
itemHeight: number, itemHeight: number,
ignoreIds: string[] = [], ignoreIds: string[] = [],
): { valid: boolean; conflictIds: string[] } { ): { valid: boolean; conflictIds: string[] } {
const halfW = itemWidth / wallLength / 2; const halfW = itemWidth / wallLength / 2
const tStart = tCenter - halfW; const tStart = tCenter - halfW
const tEnd = tCenter + halfW; const tEnd = tCenter + halfW
// yBottom is the bottom of the item, so yEnd = yBottom + itemHeight // yBottom is the bottom of the item, so yEnd = yBottom + itemHeight
const yStart = yBottom; const yStart = yBottom
const yEnd = yBottom + itemHeight; const yEnd = yBottom + itemHeight
// Check wall boundaries // Check wall boundaries
if (tStart < 0 || tEnd > 1 || yStart < 0 || yEnd > wallHeight) { if (tStart < 0 || tEnd > 1 || yStart < 0 || yEnd > wallHeight) {
return { valid: false, conflictIds: [] }; return { valid: false, conflictIds: [] }
} }
const existing = this.wallItems.get(wallId) ?? []; const existing = this.wallItems.get(wallId) ?? []
const ignoreSet = new Set(ignoreIds); const ignoreSet = new Set(ignoreIds)
const conflicts: string[] = []; const conflicts: string[] = []
for (const placement of existing) { 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 tOverlap = tStart < placement.tEnd && tEnd > placement.tStart
const yOverlap = yStart < placement.yEnd && yEnd > placement.yStart; const yOverlap = yStart < placement.yEnd && yEnd > placement.yStart
if (tOverlap && yOverlap) { 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) { insert(placement: WallItemPlacement) {
const { wallId, itemId } = placement; const { wallId, itemId } = placement
if (!this.wallItems.has(wallId)) { if (!this.wallItems.has(wallId)) {
this.wallItems.set(wallId, []); this.wallItems.set(wallId, [])
} }
this.wallItems.get(wallId)!.push(placement); this.wallItems.get(wallId)!.push(placement)
this.itemToWall.set(itemId, wallId); this.itemToWall.set(itemId, wallId)
} }
remove(wallId: string, itemId: string) { remove(wallId: string, itemId: string) {
const items = this.wallItems.get(wallId); const items = this.wallItems.get(wallId)
if (items) { if (items) {
const idx = items.findIndex((p) => p.itemId === itemId); const idx = items.findIndex((p) => p.itemId === itemId)
if (idx !== -1) items.splice(idx, 1); if (idx !== -1) items.splice(idx, 1)
} }
this.itemToWall.delete(itemId); this.itemToWall.delete(itemId)
} }
removeByItemId(itemId: string) { removeByItemId(itemId: string) {
const wallId = this.itemToWall.get(itemId); const wallId = this.itemToWall.get(itemId)
if (wallId) { if (wallId) {
this.remove(wallId, itemId); this.remove(wallId, itemId)
} }
} }
// Useful for when a wall is deleted - remove all items on it // Useful for when a wall is deleted - remove all items on it
removeWall(wallId: string): string[] { removeWall(wallId: string): string[] {
const items = this.wallItems.get(wallId) ?? []; const items = this.wallItems.get(wallId) ?? []
const removedIds = items.map((p) => p.itemId); const removedIds = items.map((p) => p.itemId)
for (const itemId of removedIds) { 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 // Get which wall an item is on
getWallForItem(itemId: string): string | undefined { getWallForItem(itemId: string): string | undefined {
return this.itemToWall.get(itemId); return this.itemToWall.get(itemId)
} }
clear() { clear() {
this.wallItems.clear(); this.wallItems.clear()
this.itemToWall.clear(); this.itemToWall.clear()
} }
} }
+16 -20
View File
@@ -1,27 +1,23 @@
// Store // 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 // Hooks
export { export {
sceneRegistry, sceneRegistry,
useRegistry, useRegistry,
} from "./hooks/scene-registry/scene-registry"; } from './hooks/scene-registry/scene-registry'
export { initSpatialGridSync } from './hooks/spatial-grid/spatial-grid-sync'
export { useSpatialQuery } from "./hooks/spatial-grid/use-spatial-query"; 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";
// Schema // 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 { customAlphabet } from 'nanoid'
import { z } from "zod"; import { z } from 'zod'
import { CameraSchema } from "./camera"; import { CameraSchema } from './camera'
const customId = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 16); const customId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16)
/** /**
* Material preset name reference * Material preset name reference
* @example 'white', 'brick', 'wood', 'glass', 'preview-valid' * @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}` => 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) => { 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>); return schema.default(() => generateId(prefix) as z.infer<typeof schema>)
}; }
export const nodeType = <T extends string>(type: T) => export const nodeType = <T extends string>(type: T) => z.literal(type).default(type)
z.literal(type).default(type);
export const BaseNode = z.object({ 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 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(), name: z.string().optional(),
parentId: z.string().nullable().default(null), parentId: z.string().nullable().default(null),
visible: z.boolean().optional().default(true), visible: z.boolean().optional().default(true),
camera: CameraSchema.optional(), camera: CameraSchema.optional(),
metadata: z.json().optional().default({}), 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({ export const CameraSchema = z.object({
position: Vector3Schema, position: Vector3Schema,
target: Vector3Schema, target: Vector3Schema,
mode: z.enum(["perspective", "orthographic"]).default("perspective"), mode: z.enum(['perspective', 'orthographic']).default('perspective'),
fov: z.number().optional(), // For perspective fov: z.number().optional(), // For perspective
zoom: z.number().optional(), // For orthographic 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 // Base
export { BaseNode, generateId, objectId, nodeType, Material } from "./base"; export { BaseNode, generateId, Material, nodeType, objectId } 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";
// Camera // 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 // 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 dedent from 'dedent'
import { z } from "zod"; import { z } from 'zod'
import { BaseNode, nodeType, objectId } from "../base"; import { BaseNode, nodeType, objectId } from '../base'
import { LevelNode } from "./level"; import { LevelNode } from './level'
export const BuildingNode = BaseNode.extend({ export const BuildingNode = BaseNode.extend({
id: objectId("building"), id: objectId('building'),
type: nodeType("building"), type: nodeType('building'),
children: z.array(LevelNode.shape.id).default([]), children: z.array(LevelNode.shape.id).default([]),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), 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]), 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 - position: position in site coordinate system
- rotation: rotation 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) - 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 dedent from 'dedent'
import { z } from "zod"; import { z } from 'zod'
import { BaseNode, nodeType, objectId } from "../base"; import { BaseNode, nodeType, objectId } from '../base'
const assetSchema = z.object({ const assetSchema = z.object({
id: z.string(), id: z.string(),
@@ -9,22 +9,22 @@ const assetSchema = z.object({
thumbnail: z.string(), thumbnail: z.string(),
src: z.string(), src: z.string(),
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d] 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 // These are "Corrective" transforms to normalize the GLB
offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), 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]), 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]), scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]),
}); })
export type AssetInput = z.input<typeof assetSchema>; export type AssetInput = z.input<typeof assetSchema>
export type Asset = z.infer<typeof assetSchema>; export type Asset = z.infer<typeof assetSchema>
export const ItemNode = BaseNode.extend({ export const ItemNode = BaseNode.extend({
id: objectId("item"), id: objectId('item'),
type: nodeType("item"), type: nodeType('item'),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), 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]), 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") // Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side")
wallId: z.string().optional(), wallId: z.string().optional(),
@@ -42,6 +42,6 @@ export const ItemNode = BaseNode.extend({
- offset: corrective position offset for the model - offset: corrective position offset for the model
- rotation: corrective rotation for the model - rotation: corrective rotation for the model
- scale: corrective scale 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 dedent from 'dedent'
import { z } from "zod"; import { z } from 'zod'
import { BaseNode, nodeType, objectId } from "../base"; import { BaseNode, nodeType, objectId } from '../base'
import { WallNode } from "./wall"; import { WallNode } from './wall'
export const LevelNode = BaseNode.extend({ export const LevelNode = BaseNode.extend({
id: objectId("level"), id: objectId('level'),
type: nodeType("level"), type: nodeType('level'),
children: z.array(WallNode.shape.id).default([]), children: z.array(WallNode.shape.id).default([]),
// Specific props // Specific props
level: z.number().default(0), level: z.number().default(0),
@@ -14,7 +14,7 @@ export const LevelNode = BaseNode.extend({
Level node - used to represent a level in the building Level node - used to represent a level in the building
- children: array of floor, wall, ceiling, roof, item nodes - children: array of floor, wall, ceiling, roof, item nodes
- level: level number - 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 // lib/scenegraph/schema/nodes/site.ts
import dedent from "dedent"; import dedent from 'dedent'
import { z } from "zod"; import { z } from 'zod'
import { BaseNode, nodeType, objectId } from "../base"; import { BaseNode, nodeType, objectId } from '../base'
import { BuildingNode } from "./building"; import { BuildingNode } from './building'
import { ItemNode } from "./item"; import { ItemNode } from './item'
// 2D Polygon // 2D Polygon
const PropertyLineData = z.object({ const PropertyLineData = z.object({
type: z.literal("polygon"), type: z.literal('polygon'),
points: z.array(z.tuple([z.number(), z.number()])), points: z.array(z.tuple([z.number(), z.number()])),
}); })
// 3D Polygon/Mesh // 3D Polygon/Mesh
// const TerrainData = z.object({ // const TerrainData = z.object({
@@ -19,11 +19,11 @@ const PropertyLineData = z.object({
// }) // })
export const SiteNode = BaseNode.extend({ export const SiteNode = BaseNode.extend({
id: objectId("site"), id: objectId('site'),
type: nodeType("site"), type: nodeType('site'),
// Specific props // Specific props
polygon: PropertyLineData.optional().default({ polygon: PropertyLineData.optional().default({
type: "polygon", type: 'polygon',
// Default 30x30 square matching GRID_SIZE // Default 30x30 square matching GRID_SIZE
points: [ points: [
[0, 0], [0, 0],
@@ -34,14 +34,14 @@ export const SiteNode = BaseNode.extend({
}), }),
// terrain: TerrainData, // terrain: TerrainData,
children: z children: z
.array(z.discriminatedUnion("type", [BuildingNode, ItemNode])) .array(z.discriminatedUnion('type', [BuildingNode, ItemNode]))
.default([BuildingNode.parse({})]), .default([BuildingNode.parse({})]),
}).describe( }).describe(
dedent` dedent`
Site node - used to represent a site Site node - used to represent a site
- polygon: polygon data - polygon: polygon data
- children: array of building and item nodes - 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 dedent from 'dedent'
import { z } from "zod"; import { z } from 'zod'
import { BaseNode, nodeType, objectId } from "../base"; import { BaseNode, nodeType, objectId } from '../base'
import { ItemNode } from "./item"; import { ItemNode } from './item'
// import { DoorNode } from "./door"; // import { DoorNode } from "./door";
// import { ItemNode } from "./item"; // import { ItemNode } from "./item";
// import { WindowNode } from "./window"; // import { WindowNode } from "./window";
export const WallNode = BaseNode.extend({ export const WallNode = BaseNode.extend({
id: objectId("wall"), id: objectId('wall'),
type: nodeType("wall"), type: nodeType('wall'),
children: z.array(ItemNode.shape.id).default([]), children: z.array(ItemNode.shape.id).default([]),
// Specific props // Specific props
thickness: z.number().optional(), thickness: z.number().optional(),
@@ -24,6 +24,6 @@ export const WallNode = BaseNode.extend({
- start: start point of the wall in level coordinate system - start: start point of the wall in level coordinate system
- end: end 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 - 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 z from 'zod'
import { BuildingNode } from "./nodes/building"; import { BuildingNode } from './nodes/building'
import { ItemNode } from "./nodes/item"; import { ItemNode } from './nodes/item'
import { LevelNode } from "./nodes/level"; import { LevelNode } from './nodes/level'
import { SiteNode } from "./nodes/site"; import { SiteNode } from './nodes/site'
import { WallNode } from "./nodes/wall"; import { WallNode } from './nodes/wall'
export const AnyNode = z.discriminatedUnion("type", [ export const AnyNode = z.discriminatedUnion('type', [
SiteNode, SiteNode,
BuildingNode, BuildingNode,
LevelNode, LevelNode,
WallNode, WallNode,
ItemNode, ItemNode,
]); ])
export type AnyNode = z.infer<typeof AnyNode>; export type AnyNode = z.infer<typeof AnyNode>
export type AnyNodeType = AnyNode["type"]; export type AnyNodeType = AnyNode['type']
export type AnyNodeId = AnyNode["id"]; export type AnyNodeId = AnyNode['id']
+11 -11
View File
@@ -1,21 +1,21 @@
import dedent from "dedent"; import dedent from 'dedent'
import { z } from "zod"; import { z } from 'zod'
import { objectId } from "./base"; import { objectId } from './base'
import { LevelNode } from "./nodes/level"; import { LevelNode } from './nodes/level'
// Polygon boundary for zone area - array of [x, z] coordinates // 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 export const ZoneSchema = z
.object({ .object({
id: objectId("zone"), id: objectId('zone'),
object: z.literal("zone").default("zone"), object: z.literal('zone').default('zone'),
levelId: LevelNode.shape.id, // Required - must be attached to a level levelId: LevelNode.shape.id, // Required - must be attached to a level
name: z.string(), name: z.string(),
// Polygon boundary - array of [x, z] coordinates defining the zone // Polygon boundary - array of [x, z] coordinates defining the zone
polygon: ZonePolygon, polygon: ZonePolygon,
// Visual styling // Visual styling
color: z.string().default("#3b82f6"), // Default blue color: z.string().default('#3b82f6'), // Default blue
metadata: z.json().optional().default({}), metadata: z.json().optional().default({}),
}) })
.describe( .describe(
@@ -29,7 +29,7 @@ export const ZoneSchema = z
- color: hex color for visual styling - color: hex color for visual styling
- metadata: zone metadata (optional) - metadata: zone metadata (optional)
`, `,
); )
export type Zone = z.infer<typeof ZoneSchema>; export type Zone = z.infer<typeof ZoneSchema>
export type ZonePolygon = z.infer<typeof ZonePolygon>; export type ZonePolygon = z.infer<typeof ZonePolygon>
+50 -55
View File
@@ -1,5 +1,5 @@
import { AnyNode, AnyNodeId } from "../../schema"; import type { AnyNode, AnyNodeId } from '../../schema'
import { SceneState } from "../use-scene"; import type { SceneState } from '../use-scene'
export const createNodesAction = ( export const createNodesAction = (
set: (fn: (state: SceneState) => Partial<SceneState>) => void, set: (fn: (state: SceneState) => Partial<SceneState>) => void,
@@ -7,145 +7,140 @@ export const createNodesAction = (
ops: { node: AnyNode; parentId?: AnyNodeId }[], ops: { node: AnyNode; parentId?: AnyNodeId }[],
) => { ) => {
set((state) => { set((state) => {
const nextNodes = { ...state.nodes }; const nextNodes = { ...state.nodes }
const nextRootIds = [...state.rootNodeIds]; const nextRootIds = [...state.rootNodeIds]
for (const { node, parentId } of ops) { for (const { node, parentId } of ops) {
// 1. Assign parentId to the child (Safe because BaseNode has parentId) // 1. Assign parentId to the child (Safe because BaseNode has parentId)
const newNode = { const newNode = {
...node, ...node,
parentId: parentId ?? null, parentId: parentId ?? null,
}; }
nextNodes[newNode.id] = newNode; nextNodes[newNode.id] = newNode
// 2. Update the Parent's children list // 2. Update the Parent's children list
if (parentId && nextNodes[parentId]) { 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 // 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] = { nextNodes[parentId] = {
...parent, ...parent,
// Use Set to prevent duplicate IDs if createNode is called twice // Use Set to prevent duplicate IDs if createNode is called twice
children: Array.from( children: Array.from(new Set([...parent.children, newNode.id])) as any, // We don't verify child types here
new Set([...parent.children, newNode.id]), }
) as any, // We don't verify child types here
};
} }
} else if (!parentId) { } else if (!parentId) {
// 3. Handle Root nodes // 3. Handle Root nodes
if (!nextRootIds.includes(newNode.id)) { 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 // 4. System Sync
ops.forEach(({ node, parentId }) => { ops.forEach(({ node, parentId }) => {
get().markDirty(node.id); get().markDirty(node.id)
if (parentId) get().markDirty(parentId); if (parentId) get().markDirty(parentId)
}); })
}; }
export const updateNodesAction = ( export const updateNodesAction = (
set: (fn: (state: SceneState) => Partial<SceneState>) => void, set: (fn: (state: SceneState) => Partial<SceneState>) => void,
get: () => SceneState, get: () => SceneState,
updates: { id: AnyNodeId; data: Partial<AnyNode> }[], updates: { id: AnyNodeId; data: Partial<AnyNode> }[],
) => { ) => {
const parentsToUpdate = new Set<string>(); const parentsToUpdate = new Set<string>()
set((state) => { set((state) => {
const nextNodes = { ...state.nodes }; const nextNodes = { ...state.nodes }
for (const { id, data } of updates) { for (const { id, data } of updates) {
const currentNode = nextNodes[id]; const currentNode = nextNodes[id]
if (!currentNode) continue; if (!currentNode) continue
// Handle Reparenting Logic // Handle Reparenting Logic
if ( if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
data.parentId !== undefined &&
data.parentId !== currentNode.parentId
) {
// 1. Remove from old parent // 1. Remove from old parent
if (currentNode.parentId && nextNodes[currentNode.parentId]) { if (currentNode.parentId && nextNodes[currentNode.parentId]) {
const oldParent = nextNodes[currentNode.parentId] as AnyContainerNode; const oldParent = nextNodes[currentNode.parentId] as AnyContainerNode
nextNodes[oldParent.id] = { nextNodes[oldParent.id] = {
...oldParent, ...oldParent,
children: oldParent.children.filter((childId) => childId !== id), children: oldParent.children.filter((childId) => childId !== id),
}; }
parentsToUpdate.add(oldParent.id); parentsToUpdate.add(oldParent.id)
} }
// 2. Add to new parent // 2. Add to new parent
if (data.parentId && nextNodes[data.parentId]) { if (data.parentId && nextNodes[data.parentId]) {
const newParent = nextNodes[data.parentId] as AnyContainerNode; const newParent = nextNodes[data.parentId] as AnyContainerNode
nextNodes[newParent.id] = { nextNodes[newParent.id] = {
...newParent, ...newParent,
children: Array.from(new Set([...newParent.children, id])), children: Array.from(new Set([...newParent.children, id])),
}; }
parentsToUpdate.add(newParent.id); parentsToUpdate.add(newParent.id)
} }
} }
// Apply the update // Apply the update
nextNodes[id] = { ...nextNodes[id], ...data }; nextNodes[id] = { ...nextNodes[id], ...data }
} }
return { nodes: nextNodes }; return { nodes: nextNodes }
}); })
// Mark dirty // Mark dirty
updates.forEach((u) => get().markDirty(u.id)); updates.forEach((u) => get().markDirty(u.id))
parentsToUpdate.forEach((pId) => get().markDirty(pId)); parentsToUpdate.forEach((pId) => get().markDirty(pId))
}; }
export const deleteNodesAction = ( export const deleteNodesAction = (
set: (fn: (state: SceneState) => Partial<SceneState>) => void, set: (fn: (state: SceneState) => Partial<SceneState>) => void,
get: () => SceneState, get: () => SceneState,
ids: AnyNodeId[], ids: AnyNodeId[],
) => { ) => {
const parentsToMarkDirty = new Set<string>(); const parentsToMarkDirty = new Set<string>()
set((state) => { set((state) => {
const nextNodes = { ...state.nodes }; const nextNodes = { ...state.nodes }
let nextRootIds = [...state.rootNodeIds]; let nextRootIds = [...state.rootNodeIds]
for (const id of ids) { for (const id of ids) {
const node = nextNodes[id]; const node = nextNodes[id]
if (!node) continue; if (!node) continue
// 1. Remove reference from Parent // 1. Remove reference from Parent
if (node.parentId && nextNodes[node.parentId]) { if (node.parentId && nextNodes[node.parentId]) {
const parent = nextNodes[node.parentId] as AnyContainerNode; const parent = nextNodes[node.parentId] as AnyContainerNode
if (parent.children) { if (parent.children) {
nextNodes[parent.id] = { nextNodes[parent.id] = {
...parent, ...parent,
children: parent.children.filter((cid) => cid !== id), children: parent.children.filter((cid) => cid !== id),
}; }
parentsToMarkDirty.add(parent.id); parentsToMarkDirty.add(parent.id)
} }
} }
// 2. Remove from Root list // 2. Remove from Root list
nextRootIds = nextRootIds.filter((rid) => rid !== id); nextRootIds = nextRootIds.filter((rid) => rid !== id)
// 3. Delete the node itself // 3. Delete the node itself
delete nextNodes[id]; delete nextNodes[id]
// Inside the deleteNodes loop // 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 // 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) // 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 { temporal } from 'zundo'
import { BuildingNode, ItemNode } from "../schema"; import { create } from 'zustand'
import { LevelNode } from "../schema/nodes/level"; import { BuildingNode, ItemNode } from '../schema'
import { WallNode } from "../schema/nodes/wall"; import { LevelNode } from '../schema/nodes/level'
import { AnyNode, AnyNodeId } from "../schema/types"; import { WallNode } from '../schema/nodes/wall'
import { temporal } from "zundo"; import type { AnyNode, AnyNodeId } from '../schema/types'
import * as nodeActions from "./actions/node-actions"; import * as nodeActions from './actions/node-actions'
export type SceneState = { export type SceneState = {
// 1. The Data: A flat dictionary of all nodes // 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? // 2. The Root: Which nodes are at the top level?
rootNodeIds: AnyNodeId[]; rootNodeIds: AnyNodeId[]
// 3. The "Dirty" Set: For the Wall/Physics systems // 3. The "Dirty" Set: For the Wall/Physics systems
dirtyNodes: Set<AnyNodeId>; dirtyNodes: Set<AnyNodeId>
// Actions // Actions
loadScene: () => void; loadScene: () => void
markDirty: (id: AnyNodeId) => void; markDirty: (id: AnyNodeId) => void
clearDirty: (id: AnyNodeId) => void; clearDirty: (id: AnyNodeId) => void
createNode: (node: AnyNode, parentId?: AnyNodeId) => void; createNode: (node: AnyNode, parentId?: AnyNodeId) => void
createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void; createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void; updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void
updateNodes: (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => void; updateNodes: (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => void
deleteNode: (id: AnyNodeId) => void; deleteNode: (id: AnyNodeId) => void
deleteNodes: (ids: AnyNodeId[]) => void; deleteNodes: (ids: AnyNodeId[]) => void
}; }
// type PartializedStoreState = Pick<SceneState, 'rootNodeIds' | 'nodes'>; // type PartializedStoreState = Pick<SceneState, 'rootNodeIds' | 'nodes'>;
@@ -50,70 +50,70 @@ const useScene = create<SceneState>()(
loadScene: () => { loadScene: () => {
const building = BuildingNode.parse({ const building = BuildingNode.parse({
children: [], children: [],
}); })
const level0 = LevelNode.parse({ const level0 = LevelNode.parse({
level: 0, level: 0,
children: [], children: [],
}); })
const level1 = LevelNode.parse({ const level1 = LevelNode.parse({
level: 1, level: 1,
children: [], children: [],
}); })
const level2 = LevelNode.parse({ const level2 = LevelNode.parse({
level: 2, level: 2,
children: [], children: [],
}); })
const wall0 = WallNode.parse({ const wall0 = WallNode.parse({
start: [0, 0], start: [0, 0],
end: [5, 0], end: [5, 0],
children: [], children: [],
parentId: level0.id, parentId: level0.id,
}); })
const wall1 = WallNode.parse({ const wall1 = WallNode.parse({
start: [0, 0], start: [0, 0],
end: [0, 5], end: [0, 5],
children: [], children: [],
parentId: level0.id, parentId: level0.id,
}); })
const wall2 = WallNode.parse({ const wall2 = WallNode.parse({
start: [5, 5], start: [5, 5],
end: [0, 5], end: [0, 5],
children: [], children: [],
parentId: level0.id, parentId: level0.id,
}); })
const wall3 = WallNode.parse({ const wall3 = WallNode.parse({
start: [5, 5], start: [5, 5],
end: [5, 0], end: [5, 0],
children: [], children: [],
parentId: level1.id, parentId: level1.id,
}); })
const window1 = ItemNode.parse({ const window1 = ItemNode.parse({
type: "item", type: 'item',
name: "Window", name: 'Window',
position: [2.5, 0.5, 0], position: [2.5, 0.5, 0],
parentId: wall3.id, parentId: wall3.id,
asset: { asset: {
id: "window-round", id: 'window-round',
name: "Round Window", name: 'Round Window',
thumbnail: "/items/window-small/thumbnail.png", thumbnail: '/items/window-small/thumbnail.png',
category: "windows", category: 'windows',
attachTo: "wall", attachTo: 'wall',
src: "/items/window-small/model.glb", 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); level0.children.push(wall0.id, wall1.id, wall2.id)
level1.children.push(wall3.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 // Define all nodes flat
const nodes: Record<AnyNodeId, AnyNode> = { const nodes: Record<AnyNodeId, AnyNode> = {
@@ -126,34 +126,31 @@ const useScene = create<SceneState>()(
[wall2.id]: wall2, [wall2.id]: wall2,
[wall3.id]: wall3, [wall3.id]: wall3,
[window1.id]: window1, [window1.id]: window1,
}; }
// Root nodes are the levels // Root nodes are the levels
const rootNodeIds = [building.id]; const rootNodeIds = [building.id]
get().dirtyNodes.add(wall0.id); get().dirtyNodes.add(wall0.id)
get().dirtyNodes.add(wall1.id); get().dirtyNodes.add(wall1.id)
get().dirtyNodes.add(wall2.id); get().dirtyNodes.add(wall2.id)
get().dirtyNodes.add(wall3.id); get().dirtyNodes.add(wall3.id)
set({ nodes, rootNodeIds }); set({ nodes, rootNodeIds })
}, },
markDirty: (id) => { markDirty: (id) => {
get().dirtyNodes.add(id); get().dirtyNodes.add(id)
}, },
clearDirty: (id) => { clearDirty: (id) => {
get().dirtyNodes.delete(id); get().dirtyNodes.delete(id)
}, },
createNodes: (ops) => nodeActions.createNodesAction(set, get, ops), createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),
createNode: (node, parentId) => createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]),
nodeActions.createNodesAction(set, get, [{ node, parentId }]),
updateNodes: (updates) => updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates),
nodeActions.updateNodesAction(set, get, updates), updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]),
updateNode: (id, data) =>
nodeActions.updateNodesAction(set, get, [{ id, data }]),
// --- DELETE --- // --- DELETE ---
@@ -163,26 +160,26 @@ const useScene = create<SceneState>()(
}), }),
{ {
partialize: (state) => { partialize: (state) => {
const { nodes, rootNodeIds } = state; // Only track nodes and rootNodeIds in history const { nodes, rootNodeIds } = state // Only track nodes and rootNodeIds in history
return { nodes, rootNodeIds }; return { nodes, rootNodeIds }
}, },
limit: 50, // Limit to last 50 actions limit: 50, // Limit to last 50 actions
}, },
), ),
); )
export default useScene; export default useScene
// Subscribe to the temporal store (Undo/Redo events) // Subscribe to the temporal store (Undo/Redo events)
useScene.temporal.subscribe((state, prevState) => { useScene.temporal.subscribe((state, prevState) => {
// Check if we just jumped in time (Undo/Redo) // Check if we just jumped in time (Undo/Redo)
// If the 'nodes' object changed but it wasn't a normal 'set' // 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 // Trigger a full scene re-validation
Object.values(currentNodes).forEach((node) => { Object.values(currentNodes).forEach((node) => {
if (node.type === "wall") { if (node.type === 'wall') {
useScene.getState().markDirty(node.id); useScene.getState().markDirty(node.id)
} }
}); })
}); })
+93 -99
View File
@@ -1,19 +1,19 @@
import { useFrame } from "@react-three/fiber"; import { useFrame } from '@react-three/fiber'
import * as THREE from "three"; import * as THREE from 'three'
import { sceneRegistry } from "../../hooks/scene-registry/scene-registry"; import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { AnyNode, WallNode } from "../../schema"; import type { AnyNode, WallNode } from '../../schema'
import useScene from "../../store/use-scene"; import useScene from '../../store/use-scene'
export const WallSystem = () => { export const WallSystem = () => {
const { nodes, dirtyNodes, clearDirty } = useScene(); const { nodes, dirtyNodes, clearDirty } = useScene()
useFrame(() => { useFrame(() => {
if (dirtyNodes.size === 0) return; if (dirtyNodes.size === 0) return
dirtyNodes.forEach((id) => { dirtyNodes.forEach((id) => {
const node = nodes[id]; const node = nodes[id]
if (!node) return; if (!node) return
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh; const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
// 1. If a window is dirty, we actually need to redraw its PARENT wall // 1. If a window is dirty, we actually need to redraw its PARENT wall
// if ((node.type === 'window' || node.type === 'door') && node.parentId) { // if ((node.type === 'window' || node.type === 'door') && node.parentId) {
@@ -22,117 +22,111 @@ export const WallSystem = () => {
// } // }
// 2. If the wall itself is dirty // 2. If the wall itself is dirty
if (node.type === "wall" && mesh) { if (node.type === 'wall' && mesh) {
updateWallGeometry(id); 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 // Optimization: Logic moved to a vanilla function so it can be called
// by the Editor or the System without React overhead // by the Editor or the System without React overhead
function updateWallGeometry(wallId: string) { function updateWallGeometry(wallId: string) {
const node = useScene.getState().nodes[wallId as WallNode["id"]]; const node = useScene.getState().nodes[wallId as WallNode['id']]
if (!node) return; if (!node) return
if (node.type !== "wall") return; if (node.type !== 'wall') return
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh; const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
if (!mesh) return; if (!mesh) return
const childrenIds = node.children || []; const childrenIds = node.children || []
const childrenNodes = childrenIds const childrenNodes = childrenIds
.map((childId) => useScene.getState().nodes[childId]) .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 // Generate visual geometry with holes
const newGeo = generateExtrudedWall(node, childrenNodes); const newGeo = generateExtrudedWall(node, childrenNodes)
mesh.geometry.dispose(); mesh.geometry.dispose()
mesh.geometry = newGeo; mesh.geometry = newGeo
// Update collision mesh with solid geometry (no holes) // 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) { if (collisionMesh) {
const collisionGeo = generateExtrudedWall(node, []); // No children = no holes const collisionGeo = generateExtrudedWall(node, []) // No children = no holes
collisionMesh.geometry.dispose(); collisionMesh.geometry.dispose()
collisionMesh.geometry = collisionGeo; 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 // Rotate mesh to look at 'end' point
const angle = Math.atan2( const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
node.end[1] - node.start[1], mesh.rotation.y = -angle
node.end[0] - node.start[0],
);
mesh.rotation.y = -angle;
} }
export function generateExtrudedWall( export function generateExtrudedWall(wallNode: WallNode, childrenNodes: AnyNode[]) {
wallNode: WallNode,
childrenNodes: AnyNode[],
) {
// 1. Calculate Wall Dimensions // 1. Calculate Wall Dimensions
const start = new THREE.Vector2(wallNode.start[0], wallNode.start[1]); const start = new THREE.Vector2(wallNode.start[0], wallNode.start[1])
const end = new THREE.Vector2(wallNode.end[0], wallNode.end[1]); const end = new THREE.Vector2(wallNode.end[0], wallNode.end[1])
const length = start.distanceTo(end); const length = start.distanceTo(end)
const height = wallNode.height || 2.5; const height = wallNode.height || 2.5
const thickness = wallNode.thickness || 0.1; const thickness = wallNode.thickness || 0.1
// 2. Create the Main Wall Shape (a rectangle in 2D) // 2. Create the Main Wall Shape (a rectangle in 2D)
// We draw this on the XY plane, where X is "along the wall" and Y is "height" // We draw this on the XY plane, where X is "along the wall" and Y is "height"
const shape = new THREE.Shape(); const shape = new THREE.Shape()
shape.moveTo(0, 0); shape.moveTo(0, 0)
shape.lineTo(length, 0); shape.lineTo(length, 0)
shape.lineTo(length, height); shape.lineTo(length, height)
shape.lineTo(0, height); shape.lineTo(0, height)
shape.closePath(); shape.closePath()
// 3. Process Openings (Holes) // 3. Process Openings (Holes)
// Compute wall's transform info for converting world coords to wall-local coords // 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( const wallAngle = Math.atan2(
wallNode.end[1] - wallNode.start[1], wallNode.end[1] - wallNode.start[1],
wallNode.end[0] - wallNode.start[0], wallNode.end[0] - wallNode.start[0],
); )
// Get the wall mesh's world Y position (from level offset) // Get the wall mesh's world Y position (from level offset)
const wallMesh = sceneRegistry.nodes.get(wallNode.id) as THREE.Mesh; const wallMesh = sceneRegistry.nodes.get(wallNode.id) as THREE.Mesh
const wallWorldY = wallMesh?.getWorldPosition(new THREE.Vector3()).y ?? 0; const wallWorldY = wallMesh?.getWorldPosition(new THREE.Vector3()).y ?? 0
childrenNodes.forEach((child) => { childrenNodes.forEach((child) => {
// Only process items that are intended to be wall cutouts // 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) { if (!childMesh) {
return; return
} }
const cutoutMesh = childMesh.getObjectByName("cutout") as THREE.Mesh; const cutoutMesh = childMesh.getObjectByName('cutout') as THREE.Mesh
if (!cutoutMesh) return; if (!cutoutMesh) return
const holePath = createPathFromCutout(cutoutMesh, wallStart, wallAngle, wallWorldY); const holePath = createPathFromCutout(cutoutMesh, wallStart, wallAngle, wallWorldY)
if (holePath) { if (holePath) {
shape.holes.push(holePath); shape.holes.push(holePath)
} }
}); })
// 4. Extrude the Shape into 3D // 4. Extrude the Shape into 3D
const geometry = new THREE.ExtrudeGeometry(shape, { const geometry = new THREE.ExtrudeGeometry(shape, {
depth: thickness, depth: thickness,
bevelEnabled: false, bevelEnabled: false,
}); })
// 5. Pivot Alignment // 5. Pivot Alignment
// Center the geometry thickness so the "start/end" line is in the middle of the wall // 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, wallAngle: number,
wallWorldY: number, wallWorldY: number,
): THREE.Path | null { ): THREE.Path | null {
const geometry = cutoutMesh.geometry; const geometry = cutoutMesh.geometry
if (!geometry) return null; if (!geometry) return null
const positions = geometry.attributes.position; const positions = geometry.attributes.position
if (!positions) return null; if (!positions) return null
// Update world matrix to get correct world positions // 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) // Collect unique vertices (buffer geometry has duplicates for triangulation)
const uniquePoints: THREE.Vector2[] = []; const uniquePoints: THREE.Vector2[] = []
const seen = new Set<string>(); const seen = new Set<string>()
const v3 = new THREE.Vector3(); const v3 = new THREE.Vector3()
// Precompute sin/cos for rotation // Precompute sin/cos for rotation
const cosAngle = Math.cos(-wallAngle); const cosAngle = Math.cos(-wallAngle)
const sinAngle = Math.sin(-wallAngle); const sinAngle = Math.sin(-wallAngle)
for (let i = 0; i < positions.count; i++) { for (let i = 0; i < positions.count; i++) {
v3.fromBufferAttribute(positions, i); v3.fromBufferAttribute(positions, i)
// Transform to world space // Transform to world space
v3.applyMatrix4(cutoutMesh.matrixWorld); v3.applyMatrix4(cutoutMesh.matrixWorld)
// Transform from world space to wall-local space: // Transform from world space to wall-local space:
// 1. Translate so wall start is at origin (in XZ plane) // 1. Translate so wall start is at origin (in XZ plane)
const worldX = v3.x - wallStart[0]; const worldX = v3.x - wallStart[0]
const worldZ = v3.z - wallStart[1]; const worldZ = v3.z - wallStart[1]
// 2. Rotate around Y axis to align wall with local X axis // 2. Rotate around Y axis to align wall with local X axis
// The wall shape is drawn on XY plane, so we need: // The wall shape is drawn on XY plane, so we need:
// - localX = distance along wall // - localX = distance along wall
// - localY = height relative to wall's Y position // - localY = height relative to wall's Y position
const localX = worldX * cosAngle - worldZ * sinAngle; const localX = worldX * cosAngle - worldZ * sinAngle
const localY = v3.y - wallWorldY; // Subtract wall's world Y to get local height const localY = v3.y - wallWorldY // Subtract wall's world Y to get local height
// Create a key for deduplication (with small tolerance) // 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)) { if (!seen.has(key)) {
seen.add(key); seen.add(key)
uniquePoints.push(new THREE.Vector2(localX, localY)); 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 // 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) { for (const p of uniquePoints) {
centroid.add(p); centroid.add(p)
} }
centroid.divideScalar(uniquePoints.length); centroid.divideScalar(uniquePoints.length)
uniquePoints.sort((a, b) => { uniquePoints.sort((a, b) => {
const angleA = Math.atan2(a.y - centroid.y, a.x - centroid.x); const angleA = Math.atan2(a.y - centroid.y, a.x - centroid.x)
const angleB = Math.atan2(b.y - centroid.y, b.x - centroid.x); const angleB = Math.atan2(b.y - centroid.y, b.x - centroid.x)
return angleA - angleB; return angleA - angleB
}); })
// Create the path // Create the path
const path = new THREE.Path(); const path = new THREE.Path()
path.moveTo(uniquePoints[0]?.x || 0, uniquePoints[0]?.y || 0); path.moveTo(uniquePoints[0]?.x || 0, uniquePoints[0]?.y || 0)
for (let i = 1; i < uniquePoints.length; i++) { 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 js from '@eslint/js'
import eslintConfigPrettier from "eslint-config-prettier"; import eslintConfigPrettier from 'eslint-config-prettier'
import turboPlugin from "eslint-plugin-turbo"; import onlyWarn from 'eslint-plugin-only-warn'
import tseslint from "typescript-eslint"; import turboPlugin from 'eslint-plugin-turbo'
import onlyWarn from "eslint-plugin-only-warn"; import tseslint from 'typescript-eslint'
/** /**
* A shared ESLint configuration for the repository. * A shared ESLint configuration for the repository.
@@ -18,7 +18,7 @@ export const config = [
turbo: turboPlugin, turbo: turboPlugin,
}, },
rules: { 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 js from '@eslint/js'
import { globalIgnores } from "eslint/config"; import pluginNext from '@next/eslint-plugin-next'
import eslintConfigPrettier from "eslint-config-prettier"; import { globalIgnores } from 'eslint/config'
import tseslint from "typescript-eslint"; import eslintConfigPrettier from 'eslint-config-prettier'
import pluginReactHooks from "eslint-plugin-react-hooks"; import pluginReact from 'eslint-plugin-react'
import pluginReact from "eslint-plugin-react"; import pluginReactHooks from 'eslint-plugin-react-hooks'
import globals from "globals"; import globals from 'globals'
import pluginNext from "@next/eslint-plugin-next"; import tseslint from 'typescript-eslint'
import { config as baseConfig } from "./base.js"; import { config as baseConfig } from './base.js'
/** /**
* A custom ESLint configuration for libraries that use Next.js. * A custom ESLint configuration for libraries that use Next.js.
@@ -20,10 +20,10 @@ export const nextJsConfig = [
...tseslint.configs.recommended, ...tseslint.configs.recommended,
globalIgnores([ globalIgnores([
// Default ignores of eslint-config-next: // Default ignores of eslint-config-next:
".next/**", '.next/**',
"out/**", 'out/**',
"build/**", 'build/**',
"next-env.d.ts", 'next-env.d.ts',
]), ]),
{ {
...pluginReact.configs.flat.recommended, ...pluginReact.configs.flat.recommended,
@@ -36,22 +36,22 @@ export const nextJsConfig = [
}, },
{ {
plugins: { plugins: {
"@next/next": pluginNext, '@next/next': pluginNext,
}, },
rules: { rules: {
...pluginNext.configs.recommended.rules, ...pluginNext.configs.recommended.rules,
...pluginNext.configs["core-web-vitals"].rules, ...pluginNext.configs['core-web-vitals'].rules,
}, },
}, },
{ {
plugins: { plugins: {
"react-hooks": pluginReactHooks, 'react-hooks': pluginReactHooks,
}, },
settings: { react: { version: "detect" } }, settings: { react: { version: 'detect' } },
rules: { rules: {
...pluginReactHooks.configs.recommended.rules, ...pluginReactHooks.configs.recommended.rules,
// React scope no longer necessary with new JSX transform. // 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 js from '@eslint/js'
import eslintConfigPrettier from "eslint-config-prettier"; import eslintConfigPrettier from 'eslint-config-prettier'
import tseslint from "typescript-eslint"; import pluginReact from 'eslint-plugin-react'
import pluginReactHooks from "eslint-plugin-react-hooks"; import pluginReactHooks from 'eslint-plugin-react-hooks'
import pluginReact from "eslint-plugin-react"; import globals from 'globals'
import globals from "globals"; import tseslint from 'typescript-eslint'
import { config as baseConfig } from "./base.js"; import { config as baseConfig } from './base.js'
/** /**
* A custom ESLint configuration for libraries that use React. * A custom ESLint configuration for libraries that use React.
@@ -27,13 +27,13 @@ export const config = [
}, },
{ {
plugins: { plugins: {
"react-hooks": pluginReactHooks, 'react-hooks': pluginReactHooks,
}, },
settings: { react: { version: "detect" } }, settings: { react: { version: 'detect' } },
rules: { rules: {
...pluginReactHooks.configs.recommended.rules, ...pluginReactHooks.configs.recommended.rules,
// React scope no longer necessary with new JSX transform. // 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 { interface ButtonProps {
children: ReactNode; children: ReactNode
className?: string; className?: string
appName: string; appName: string
} }
export const Button = ({ children, className, appName }: ButtonProps) => { export const Button = ({ children, className, appName }: ButtonProps) => {
return ( return (
<button <button className={className} onClick={() => alert(`Hello from your ${appName} app!`)}>
className={className}
onClick={() => alert(`Hello from your ${appName} app!`)}
>
{children} {children}
</button> </button>
); )
}; }
+6 -6
View File
@@ -1,4 +1,4 @@
import { type JSX } from "react"; import type { JSX } from 'react'
export function Card({ export function Card({
className, className,
@@ -6,10 +6,10 @@ export function Card({
children, children,
href, href,
}: { }: {
className?: string; className?: string
title: string; title: string
children: React.ReactNode; children: React.ReactNode
href: string; href: string
}): JSX.Element { }): JSX.Element {
return ( return (
<a <a
@@ -23,5 +23,5 @@ export function Card({
</h2> </h2>
<p>{children}</p> <p>{children}</p>
</a> </a>
); )
} }
+4 -4
View File
@@ -1,11 +1,11 @@
import { type JSX } from "react"; import type { JSX } from 'react'
export function Code({ export function Code({
children, children,
className, className,
}: { }: {
children: React.ReactNode; children: React.ReactNode
className?: string; className?: string
}): JSX.Element { }): 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 { type BuildingNode, useRegistry } from '@pascal-app/core'
import { useRef } from "react"; import { useRef } from 'react'
import { Group } from "three"; import type { Group } from 'three'
import { NodeRenderer } from "../node-renderer"; import { useNodeEvents } from '../../../hooks/use-node-events'
import { useNodeEvents } from "../../../hooks/use-node-events"; import { NodeRenderer } from '../node-renderer'
export const BuildingRenderer = ({ node }: { node: BuildingNode }) => { export const BuildingRenderer = ({ node }: { node: BuildingNode }) => {
const ref = useRef<Group>(null!); const ref = useRef<Group>(null!)
useRegistry(node.id, node.type, ref); useRegistry(node.id, node.type, ref)
const handlers = useNodeEvents(node, "building"); const handlers = useNodeEvents(node, 'building')
return ( return (
<group ref={ref} {...handlers}> <group ref={ref} {...handlers}>
{node.children.map((childId) => ( {node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} /> <NodeRenderer key={childId} nodeId={childId} />
))} ))}
</group> </group>
); )
}; }
@@ -1,21 +1,14 @@
import { import { type AnyNodeId, type ItemNode, useRegistry, useScene } from '@pascal-app/core'
AnyNodeId, import { Clone } from '@react-three/drei/core/Clone'
emitter, import { useGLTF } from '@react-three/drei/core/Gltf'
ItemNode, import { Suspense, useEffect, useRef } from 'react'
useRegistry, import type { Group } from 'three'
useScene, import { useNodeEvents } from '../../../hooks/use-node-events'
} 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";
export const ItemRenderer = ({ node }: { node: ItemNode }) => { 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 ( return (
<group position={node.position} rotation={node.rotation} ref={ref}> <group position={node.position} rotation={node.rotation} ref={ref}>
@@ -23,22 +16,22 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
<ModelRenderer node={node} /> <ModelRenderer node={node} />
</Suspense> </Suspense>
</group> </group>
); )
}; }
const ModelRenderer = ({ node }: { node: ItemNode }) => { const ModelRenderer = ({ node }: { node: ItemNode }) => {
const { scene, nodes } = useGLTF(node.asset.src); const { scene, nodes } = useGLTF(node.asset.src)
if (nodes.cutout) { if (nodes.cutout) {
nodes.cutout.visible = false; nodes.cutout.visible = false
} }
const handlers = useNodeEvents(node, "item"); const handlers = useNodeEvents(node, 'item')
useEffect(() => { useEffect(() => {
if (!node.parentId) return; if (!node.parentId) return
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId); useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
}, []); }, [node.parentId])
return ( return (
<Clone <Clone
@@ -48,5 +41,5 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
rotation={node.asset.rotation} rotation={node.asset.rotation}
{...handlers} {...handlers}
/> />
); )
}; }
@@ -1,12 +1,12 @@
import { LevelNode, useRegistry } from "@pascal-app/core"; import { type LevelNode, useRegistry } from '@pascal-app/core'
import { useRef } from "react"; import { useRef } from 'react'
import { Group } from "three"; import type { Group } from 'three'
import { NodeRenderer } from "../node-renderer"; import { NodeRenderer } from '../node-renderer'
export const LevelRenderer = ({ node }: { node: LevelNode }) => { 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 ( return (
<group ref={ref}> <group ref={ref}>
@@ -18,5 +18,5 @@ export const LevelRenderer = ({ node }: { node: LevelNode }) => {
<NodeRenderer key={childId} nodeId={childId} /> <NodeRenderer key={childId} nodeId={childId} />
))} ))}
</group> </group>
); )
}; }
@@ -1,22 +1,22 @@
"use client"; 'use client'
import { AnyNode, useScene } from "@pascal-app/core"; import { type AnyNode, useScene } from '@pascal-app/core'
import { ItemRenderer } from "./item/item-renderer"; import { BuildingRenderer } from './building/building-renderer'
import { LevelRenderer } from "./level/level-renderer"; import { ItemRenderer } from './item/item-renderer'
import { WallRenderer } from "./wall/wall-renderer"; import { LevelRenderer } from './level/level-renderer'
import { BuildingRenderer } from "./building/building-renderer"; import { WallRenderer } from './wall/wall-renderer'
export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode["id"] }) => { export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
const node = useScene((state) => state.nodes[nodeId]); const node = useScene((state) => state.nodes[nodeId])
if (!node) return null; if (!node) return null
return ( return (
<> <>
{node.type === "building" && <BuildingRenderer node={node} />} {node.type === 'building' && <BuildingRenderer node={node} />}
{node.type === "level" && <LevelRenderer node={node} />} {node.type === 'level' && <LevelRenderer node={node} />}
{node.type === "item" && <ItemRenderer node={node} />} {node.type === 'item' && <ItemRenderer node={node} />}
{node.type === "wall" && <WallRenderer node={node} />} {node.type === 'wall' && <WallRenderer node={node} />}
</> </>
); )
}; }
@@ -1,12 +1,10 @@
"use client"; 'use client'
import { useScene } from "@pascal-app/core"; import { useScene } from '@pascal-app/core'
import { NodeRenderer } from "./node-renderer"; import { NodeRenderer } from './node-renderer'
export const SceneRenderer = () => { export const SceneRenderer = () => {
const rootNodes = useScene((state) => state.rootNodeIds); const rootNodes = useScene((state) => state.rootNodeIds)
return rootNodes.map((nodeId) => ( return rootNodes.map((nodeId) => <NodeRenderer key={nodeId} nodeId={nodeId} />)
<NodeRenderer key={nodeId} nodeId={nodeId} /> }
));
};
@@ -1,15 +1,15 @@
import { useRegistry, WallNode } from "@pascal-app/core"; import { useRegistry, type WallNode } from '@pascal-app/core'
import { useRef } from "react"; import { useRef } from 'react'
import { Mesh } from "three"; import type { Mesh } from 'three'
import { NodeRenderer } from "../node-renderer"; import { useNodeEvents } from '../../../hooks/use-node-events'
import { useNodeEvents } from "../../../hooks/use-node-events"; import { NodeRenderer } from '../node-renderer'
export const WallRenderer = ({ node }: { node: WallNode }) => { 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 ( return (
<mesh ref={ref} castShadow receiveShadow> <mesh ref={ref} castShadow receiveShadow>
@@ -24,5 +24,5 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
<NodeRenderer key={childId} nodeId={childId} /> <NodeRenderer key={childId} nodeId={childId} />
))} ))}
</mesh> </mesh>
); )
}; }
+19 -21
View File
@@ -1,38 +1,36 @@
"use client"; 'use client'
import { Bvh, Environment, OrbitControls } from "@react-three/drei"; import { WallSystem } from '@pascal-app/core'
import { Canvas, ThreeToJSXElements } from "@react-three/fiber"; 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"; declare module '@react-three/fiber' {
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" {
interface ThreeElements extends ThreeToJSXElements<typeof THREE> {} interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}
} }
extend(THREE as any); extend(THREE as any)
interface ViewerProps { interface ViewerProps {
children?: React.ReactNode; children?: React.ReactNode
} }
const Viewer: React.FC<ViewerProps> = ({ children }) => { const Viewer: React.FC<ViewerProps> = ({ children }) => {
return ( return (
<Canvas <Canvas
className={"bg-[#303035]"} className={'bg-[#303035]'}
gl={async (props) => { gl={async (props) => {
const renderer = new THREE.WebGPURenderer(props as any); const renderer = new THREE.WebGPURenderer(props as any)
await renderer.init(); await renderer.init()
return renderer; return renderer
}} }}
shadows shadows
camera={{ position: [50, 50, 50], fov: 50 }} camera={{ position: [50, 50, 50], fov: 50 }}
> >
<color attach="background" args={["#ececec"]} /> <color attach="background" args={['#ececec']} />
<Environment preset="sunset" /> <Environment preset="sunset" />
<Bvh> <Bvh>
@@ -46,7 +44,7 @@ const Viewer: React.FC<ViewerProps> = ({ children }) => {
{children} {children}
</Canvas> </Canvas>
); )
}; }
export default Viewer; export default Viewer
@@ -1,99 +1,93 @@
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from '@react-three/fiber'
import { useEffect, useRef } from "react"; import { useEffect, useRef } from 'react'
import { Color, Object3D } from "three"; import { Color } from 'three'
import { oscSine, pass, time, uniform } from "three/tsl"; import { outline } from 'three/addons/tsl/display/OutlineNode.js'
import { outline } from "three/addons/tsl/display/OutlineNode.js"; import { oscSine, pass, time, uniform } from 'three/tsl'
import { PostProcessing, WebGPURenderer } from "three/webgpu"; import { PostProcessing, type WebGPURenderer } from 'three/webgpu'
import useViewer from "../../store/use-viewer"; import useViewer from '../../store/use-viewer'
const PostProcessingPasses = ({}) => { const PostProcessingPasses = ({}) => {
const { gl: renderer, scene, camera } = useThree(); const { gl: renderer, scene, camera } = useThree()
const postProcessingRef = useRef<PostProcessing | null>(null); const postProcessingRef = useRef<PostProcessing | null>(null)
useEffect(() => { useEffect(() => {
if (!renderer || !scene || !camera) { if (!renderer || !scene || !camera) {
return; return
} }
const scenePass = pass(scene, camera); const scenePass = pass(scene, camera)
function generateSelectedOutlinePass() { function generateSelectedOutlinePass() {
const edgeStrength = uniform(3); const edgeStrength = uniform(3)
const edgeGlow = uniform(0); const edgeGlow = uniform(0)
const edgeThickness = uniform(1); const edgeThickness = uniform(1)
const visibleEdgeColor = uniform(new Color(0xffffff)); const visibleEdgeColor = uniform(new Color(0xffffff))
const hiddenEdgeColor = uniform(new Color(0xf3ff47)); const hiddenEdgeColor = uniform(new Color(0xf3ff47))
const outlinePass = outline(scene, camera, { const outlinePass = outline(scene, camera, {
selectedObjects: useViewer.getState().outliner.selectedObjects, selectedObjects: useViewer.getState().outliner.selectedObjects,
edgeGlow, edgeGlow,
edgeThickness, edgeThickness,
}); })
const { visibleEdge, hiddenEdge } = outlinePass; const { visibleEdge, hiddenEdge } = outlinePass
const outlineColor = visibleEdge const outlineColor = visibleEdge
.mul(visibleEdgeColor) .mul(visibleEdgeColor)
.add(hiddenEdge.mul(hiddenEdgeColor)) .add(hiddenEdge.mul(hiddenEdgeColor))
.mul(edgeStrength); .mul(edgeStrength)
return outlineColor; return outlineColor
} }
function generateHoverOutlinePass() { function generateHoverOutlinePass() {
const edgeStrength = uniform(5); const edgeStrength = uniform(5)
const edgeGlow = uniform(0.5); const edgeGlow = uniform(0.5)
const edgeThickness = uniform(1.5); const edgeThickness = uniform(1.5)
const pulsePeriod = uniform(3); const pulsePeriod = uniform(3)
const visibleEdgeColor = uniform(new Color(0x00aaff)); const visibleEdgeColor = uniform(new Color(0x00aaff))
const hiddenEdgeColor = uniform(new Color(0xf3ff47)); const hiddenEdgeColor = uniform(new Color(0xf3ff47))
const outlinePass = outline(scene, camera, { const outlinePass = outline(scene, camera, {
selectedObjects: useViewer.getState().outliner.hoveredObjects, selectedObjects: useViewer.getState().outliner.hoveredObjects,
edgeGlow, edgeGlow,
edgeThickness, edgeThickness,
}); })
const { visibleEdge, hiddenEdge } = outlinePass; const { visibleEdge, hiddenEdge } = outlinePass
const period = time.div(pulsePeriod).mul(2); const period = time.div(pulsePeriod).mul(2)
const osc = oscSine(period).mul(0.5).add(0.5); // osc [ 0.5, 1.0 ] const osc = oscSine(period).mul(0.5).add(0.5) // osc [ 0.5, 1.0 ]
const outlineColor = visibleEdge const outlineColor = visibleEdge
.mul(visibleEdgeColor) .mul(visibleEdgeColor)
.add(hiddenEdge.mul(hiddenEdgeColor)) .add(hiddenEdge.mul(hiddenEdgeColor))
.mul(edgeStrength); .mul(edgeStrength)
const outlinePulse = pulsePeriod const outlinePulse = pulsePeriod.greaterThan(0).select(outlineColor.mul(osc), outlineColor)
.greaterThan(0) return outlinePulse
.select(outlineColor.mul(osc), outlineColor);
return outlinePulse;
} }
// Setup post-processing // Setup post-processing
const postProcessing = new PostProcessing( const postProcessing = new PostProcessing(renderer as unknown as WebGPURenderer)
renderer as unknown as WebGPURenderer,
);
const selectedOutlinePass = generateSelectedOutlinePass(); const selectedOutlinePass = generateSelectedOutlinePass()
const hoverOutlinePass = generateHoverOutlinePass(); const hoverOutlinePass = generateHoverOutlinePass()
postProcessing.outputNode = selectedOutlinePass postProcessing.outputNode = selectedOutlinePass.add(hoverOutlinePass).add(scenePass)
.add(hoverOutlinePass) postProcessingRef.current = postProcessing
.add(scenePass);
postProcessingRef.current = postProcessing;
return () => { return () => {
if (postProcessingRef.current) { if (postProcessingRef.current) {
postProcessingRef.current.dispose(); postProcessingRef.current.dispose()
} }
postProcessingRef.current = null; postProcessingRef.current = null
}; }
}, [renderer, scene, camera]); }, [renderer, scene, camera])
useFrame(() => { useFrame(() => {
if (postProcessingRef.current) { 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 { type EventSuffix, emitter, type GridEvent } from '@pascal-app/core'
import { ThreeEvent } from "@react-three/fiber"; import type { ThreeEvent } from '@react-three/fiber'
export function useGridEvents() { export function useGridEvents() {
const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => { const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {
const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`; const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`
const payload: GridEvent = { const payload: GridEvent = {
position: [e.point.x, e.point.y, e.point.z], position: [e.point.x, e.point.y, e.point.z],
}; }
emitter.emit(eventKey, payload); emitter.emit(eventKey, payload)
}; }
return { return {
onPointerDown: (e: ThreeEvent<PointerEvent>) => { onPointerDown: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return; if (e.button !== 0) return
emit("pointerdown", e); emit('pointerdown', e)
}, },
onPointerUp: (e: ThreeEvent<PointerEvent>) => { onPointerUp: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return; if (e.button !== 0) return
emit("pointerup", e); emit('pointerup', e)
}, },
onClick: (e: ThreeEvent<PointerEvent>) => { onClick: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return; if (e.button !== 0) return
emit("click", e); emit('click', e)
}, },
onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit("enter", e), onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit('enter', e),
onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit("leave", e), onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit('leave', e),
onPointerMove: (e: ThreeEvent<PointerEvent>) => emit("move", e), onPointerMove: (e: ThreeEvent<PointerEvent>) => emit('move', e),
onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit("double-click", e), onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit('double-click', e),
onContextMenu: (e: ThreeEvent<PointerEvent>) => emit("context-menu", e), onContextMenu: (e: ThreeEvent<PointerEvent>) => emit('context-menu', e),
}; }
} }
+33 -38
View File
@@ -1,60 +1,55 @@
import { import {
BuildingNode, type BuildingNode,
type EventSuffix,
emitter, emitter,
EventSuffix, type ItemEvent,
ItemEvent, type ItemNode,
ItemNode, type WallEvent,
WallEvent, type WallNode,
WallNode, } from '@pascal-app/core'
} from "@pascal-app/core"; import type { ThreeEvent } from '@react-three/fiber'
import { ThreeEvent } from "@react-three/fiber"; import type { BuildingEvent } from '../../../core/src/events/bus'
import { BuildingEvent } from "../../../core/src/events/bus";
type NodeConfig = { type NodeConfig = {
item: { node: ItemNode; event: ItemEvent }; item: { node: ItemNode; event: ItemEvent }
wall: { node: WallNode; event: WallEvent }; wall: { node: WallNode; event: WallEvent }
building: { node: BuildingNode; event: BuildingEvent }; building: { node: BuildingNode; event: BuildingEvent }
}; }
type NodeType = keyof NodeConfig; type NodeType = keyof NodeConfig
export function useNodeEvents<T extends NodeType>( export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], type: T) {
node: NodeConfig[T]["node"],
type: T,
) {
const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => { const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {
const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`; const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`
const localPoint = e.object.worldToLocal(e.point.clone()); const localPoint = e.object.worldToLocal(e.point.clone())
const payload = { const payload = {
node, node,
position: [e.point.x, e.point.y, e.point.z], position: [e.point.x, e.point.y, e.point.z],
localPosition: [localPoint.x, localPoint.y, localPoint.z], localPosition: [localPoint.x, localPoint.y, localPoint.z],
normal: e.face normal: e.face ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] : undefined,
? [e.face.normal.x, e.face.normal.y, e.face.normal.z]
: undefined,
stopPropagation: () => e.stopPropagation(), stopPropagation: () => e.stopPropagation(),
} as NodeConfig[T]["event"]; } as NodeConfig[T]['event']
emitter.emit(eventKey, payload); emitter.emit(eventKey, payload)
}; }
return { return {
onPointerDown: (e: ThreeEvent<PointerEvent>) => { onPointerDown: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return; if (e.button !== 0) return
emit("pointerdown", e); emit('pointerdown', e)
}, },
onPointerUp: (e: ThreeEvent<PointerEvent>) => { onPointerUp: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return; if (e.button !== 0) return
emit("pointerup", e); emit('pointerup', e)
}, },
onClick: (e: ThreeEvent<PointerEvent>) => { onClick: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return; if (e.button !== 0) return
emit("click", e); emit('click', e)
}, },
onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit("enter", e), onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit('enter', e),
onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit("leave", e), onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit('leave', e),
onPointerMove: (e: ThreeEvent<PointerEvent>) => emit("move", e), onPointerMove: (e: ThreeEvent<PointerEvent>) => emit('move', e),
onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit("double-click", e), onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit('double-click', e),
onContextMenu: (e: ThreeEvent<PointerEvent>) => emit("context-menu", 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 Viewer } from './components/viewer'
export { useGridEvents } from './hooks/use-grid-events'
export { default as useViewer } from "./store/use-viewer"; export { default as useViewer } from './store/use-viewer'
export { useGridEvents } from "./hooks/use-grid-events";
+30 -30
View File
@@ -1,58 +1,58 @@
"use client"; 'use client'
import { BuildingNode, ItemNode, LevelNode, Zone } from "@pascal-app/core"; import type { BuildingNode, ItemNode, LevelNode, Zone } from '@pascal-app/core'
import { Object3D } from "three"; import type { Object3D } from 'three'
import { create } from "zustand"; import { create } from 'zustand'
type SelectionPath = { type SelectionPath = {
buildingId: BuildingNode["id"] | null; buildingId: BuildingNode['id'] | null
levelId: LevelNode["id"] | null; levelId: LevelNode['id'] | null
zoneId: Zone["id"] | null; zoneId: Zone['id'] | null
selectedIds: ItemNode["id"][]; // For items/assets (multi-select) selectedIds: ItemNode['id'][] // For items/assets (multi-select)
}; }
type Outliner = { type Outliner = {
selectedObjects: Object3D[]; selectedObjects: Object3D[]
hoveredObjects: Object3D[]; hoveredObjects: Object3D[]
}; }
type ViewerState = { type ViewerState = {
selection: SelectionPath; selection: SelectionPath
levelMode: "stacked" | "exploded" | "solo" | "manual"; levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
// Actions // Actions
setLevelMode: (mode: "stacked" | "exploded" | "solo" | "manual") => void; setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void
// Smart selection update // Smart selection update
setSelection: (updates: Partial<SelectionPath>) => void; setSelection: (updates: Partial<SelectionPath>) => void
resetSelection: () => 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) => ({ const useViewer = create<ViewerState>()((set, get) => ({
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
levelMode: "stacked", levelMode: 'stacked',
setLevelMode: (mode) => set({ levelMode: mode }), setLevelMode: (mode) => set({ levelMode: mode }),
setSelection: (updates) => setSelection: (updates) =>
set((state) => { set((state) => {
const newSelection = { ...state.selection, ...updates }; const newSelection = { ...state.selection, ...updates }
// Hierarchy Guard: If we change a high-level parent, reset the children // Hierarchy Guard: If we change a high-level parent, reset the children
if (updates.buildingId !== undefined) { if (updates.buildingId !== undefined) {
newSelection.levelId = null; newSelection.levelId = null
newSelection.zoneId = null; newSelection.zoneId = null
newSelection.selectedIds = []; newSelection.selectedIds = []
} else if (updates.levelId !== undefined) { } else if (updates.levelId !== undefined) {
newSelection.zoneId = null; newSelection.zoneId = null
newSelection.selectedIds = []; newSelection.selectedIds = []
} else if (updates.zoneId !== undefined) { } else if (updates.zoneId !== undefined) {
newSelection.selectedIds = []; newSelection.selectedIds = []
} }
return { selection: newSelection }; return { selection: newSelection }
}), }),
resetSelection: () => resetSelection: () =>
@@ -66,6 +66,6 @@ const useViewer = create<ViewerState>()((set, get) => ({
}), }),
outliner: { selectedObjects: [], hoveredObjects: [] }, outliner: { selectedObjects: [], hoveredObjects: [] },
})); }))
export default useViewer; export default useViewer
@@ -1,24 +1,24 @@
import { LevelNode, sceneRegistry, useScene } from "@pascal-app/core"; import { type LevelNode, sceneRegistry, useScene } from '@pascal-app/core'
import { useFrame } from "@react-three/fiber"; import { useFrame } from '@react-three/fiber'
import { lerp } from "three/src/math/MathUtils.js"; import { lerp } from 'three/src/math/MathUtils.js'
import useViewer from "../../store/use-viewer"; import useViewer from '../../store/use-viewer'
const LEVEL_HEIGHT = 2.5; const LEVEL_HEIGHT = 2.5
const EXPLODED_GAP = 5; const EXPLODED_GAP = 5
export const LevelSystem = () => { export const LevelSystem = () => {
useFrame((_, delta) => { useFrame((_, delta) => {
const levelMode = useViewer.getState().levelMode; const levelMode = useViewer.getState().levelMode
sceneRegistry.byType.level.forEach((levelId) => { sceneRegistry.byType.level.forEach((levelId) => {
const obj = sceneRegistry.nodes.get(levelId); const obj = sceneRegistry.nodes.get(levelId)
if (obj) { if (obj) {
const level = useScene.getState().nodes[levelId as LevelNode["id"]]; const level = useScene.getState().nodes[levelId as LevelNode['id']]
const targetY = const targetY =
((level as any).level || 0) * ((level as any).level || 0) *
(LEVEL_HEIGHT + (levelMode === "stacked" ? 0 : EXPLODED_GAP)); (LEVEL_HEIGHT + (levelMode === 'stacked' ? 0 : EXPLODED_GAP))
obj.position.y = lerp(obj.position.y, targetY, delta * 3); obj.position.y = lerp(obj.position.y, targetY, delta * 3)
} }
}); })
}); })
return null; return null
}; }