From 5de08186337a801ec33a8a031704f485e9a7694d Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 21 Jan 2026 10:03:29 +0900 Subject: [PATCH] biome --- apps/editor/app/layout.tsx | 32 +- apps/editor/app/page.tsx | 4 +- .../editor/custom-camera-controls.tsx | 56 ++-- apps/editor/components/editor/index.tsx | 164 ++++------ .../components/editor/selection-manager.tsx | 31 +- .../components/tools/item/item-tool.tsx | 301 ++++++++---------- apps/editor/components/tools/tool-manager.tsx | 28 +- .../components/tools/wall/wall-tool.tsx | 98 +++--- apps/editor/hooks/use-mobile.ts | 24 +- apps/editor/lib/utils.ts | 6 +- apps/editor/next.config.ts | 6 +- apps/editor/store/use-editor.tsx | 135 ++++---- biome.jsonc | 7 +- packages/core/src/events/bus.ts | 60 ++-- .../hooks/scene-registry/scene-registry.ts | 25 +- .../spatial-grid/spatial-grid-manager.ts | 143 ++++----- .../hooks/spatial-grid/spatial-grid-sync.ts | 49 ++- .../src/hooks/spatial-grid/spatial-grid.ts | 101 +++--- .../hooks/spatial-grid/use-spatial-query.ts | 28 +- .../hooks/spatial-grid/wall-spatial-grid.ts | 80 ++--- packages/core/src/index.ts | 36 +-- packages/core/src/schema/base.ts | 29 +- packages/core/src/schema/camera.ts | 10 +- packages/core/src/schema/index.ts | 29 +- packages/core/src/schema/nodes/building.ts | 18 +- packages/core/src/schema/nodes/item.ts | 24 +- packages/core/src/schema/nodes/level.ts | 18 +- packages/core/src/schema/nodes/site.ts | 28 +- packages/core/src/schema/nodes/wall.ts | 18 +- packages/core/src/schema/types.ts | 22 +- packages/core/src/schema/zone.ts | 22 +- .../core/src/store/actions/node-actions.ts | 105 +++--- packages/core/src/store/use-scene.ts | 129 ++++---- .../core/src/systems/wall/wall-system.tsx | 192 ++++++----- packages/eslint-config/base.js | 16 +- packages/eslint-config/next.js | 38 +-- packages/eslint-config/react-internal.js | 22 +- packages/ui/src/button.tsx | 19 +- packages/ui/src/card.tsx | 12 +- packages/ui/src/code.tsx | 8 +- .../renderers/building/building-renderer.tsx | 20 +- .../renderers/item/item-renderer.tsx | 43 ++- .../renderers/level/level-renderer.tsx | 16 +- .../components/renderers/node-renderer.tsx | 30 +- .../components/renderers/scene-renderer.tsx | 14 +- .../renderers/wall/wall-renderer.tsx | 20 +- .../viewer/src/components/viewer/index.tsx | 40 ++- .../src/components/viewer/post-processing.tsx | 100 +++--- packages/viewer/src/hooks/use-grid-events.ts | 36 +-- packages/viewer/src/hooks/use-node-events.ts | 71 ++--- packages/viewer/src/index.ts | 8 +- packages/viewer/src/store/use-viewer.ts | 60 ++-- .../viewer/src/systems/level/level-system.tsx | 30 +- 53 files changed, 1241 insertions(+), 1420 deletions(-) diff --git a/apps/editor/app/layout.tsx b/apps/editor/app/layout.tsx index 84695375..2e571934 100644 --- a/apps/editor/app/layout.tsx +++ b/apps/editor/app/layout.tsx @@ -1,31 +1,29 @@ -import type { Metadata } from "next"; -import localFont from "next/font/local"; -import "./globals.css"; +import type { Metadata } from 'next' +import localFont from 'next/font/local' +import './globals.css' const geistSans = localFont({ - src: "./fonts/GeistVF.woff", - variable: "--font-geist-sans", -}); + src: './fonts/GeistVF.woff', + variable: '--font-geist-sans', +}) const geistMono = localFont({ - src: "./fonts/GeistMonoVF.woff", - variable: "--font-geist-mono", -}); + src: './fonts/GeistMonoVF.woff', + variable: '--font-geist-mono', +}) export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; + title: 'Create Next App', + description: 'Generated by create next app', +} export default function RootLayout({ children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode }>) { return ( - - {children} - + {children} - ); + ) } diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index 9c1a36ba..e9e2c256 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -1,4 +1,4 @@ -import Editor from "../components/editor"; +import Editor from '../components/editor' export default function Home() { return ( @@ -7,5 +7,5 @@ export default function Home() { - ); + ) } diff --git a/apps/editor/components/editor/custom-camera-controls.tsx b/apps/editor/components/editor/custom-camera-controls.tsx index fe6a96be..1111c1ca 100644 --- a/apps/editor/components/editor/custom-camera-controls.tsx +++ b/apps/editor/components/editor/custom-camera-controls.tsx @@ -1,46 +1,38 @@ -"use client"; +'use client' -import { sceneRegistry } from "@pascal-app/core"; -import { useViewer } from "@pascal-app/viewer"; -import { CameraControls, CameraControlsImpl } from "@react-three/drei"; -import { useEffect, useMemo, useRef } from "react"; -import { Vector3 } from "three"; +import { sceneRegistry } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { CameraControls, CameraControlsImpl } from '@react-three/drei' +import { useEffect, useMemo, useRef } from 'react' +import { Vector3 } from 'three' -const currentTarget = new Vector3(); +const currentTarget = new Vector3() export const CustomCameraControls = () => { - const controls = useRef(null!); - const currentLevelId = useViewer((state) => state.selection.levelId); - const firstLoad = useRef(true); + const controls = useRef(null!) + const currentLevelId = useViewer((state) => state.selection.levelId) + const firstLoad = useRef(true) useEffect(() => { - let targetY = 0; + let targetY = 0 if (currentLevelId) { - const levelMesh = sceneRegistry.nodes.get(currentLevelId); + const levelMesh = sceneRegistry.nodes.get(currentLevelId) if (levelMesh) { - targetY = levelMesh.position.y; + targetY = levelMesh.position.y } } if (firstLoad.current) { - firstLoad.current = false; - (controls.current as CameraControlsImpl).setLookAt( - 20, - 20, - 20, - 0, - 0, - 0, - true, - ); + firstLoad.current = false + ;(controls.current as CameraControlsImpl).setLookAt(20, 20, 20, 0, 0, 0, true) } - (controls.current as CameraControlsImpl).getTarget(currentTarget); - (controls.current as CameraControlsImpl).moveTo( + ;(controls.current as CameraControlsImpl).getTarget(currentTarget) + ;(controls.current as CameraControlsImpl).moveTo( currentTarget.x, targetY, currentTarget.z, true, - ); - }, [currentLevelId]); + ) + }, [currentLevelId]) // Configure mouse buttons based on control mode and camera mode const mouseButtons = useMemo(() => { @@ -49,15 +41,15 @@ export const CustomCameraControls = () => { // cameraMode === 'orthographic' // ? CameraControlsImpl.ACTION.ZOOM // : CameraControlsImpl.ACTION.DOLLY - const wheelAction = CameraControlsImpl.ACTION.DOLLY; + const wheelAction = CameraControlsImpl.ACTION.DOLLY return { left: CameraControlsImpl.ACTION.NONE, middle: CameraControlsImpl.ACTION.SCREEN_PAN, right: CameraControlsImpl.ACTION.ROTATE, wheel: wheelAction, - }; - }, []); + } + }, []) - return ; -}; + return +} diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index 86bc4102..692a78a2 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -1,42 +1,23 @@ -"use client"; +'use client' -import { - emitter, - initSpatialGridSync, - ItemNode, - sceneRegistry, - useRegistry, - useScene, - WallNode, -} from "@pascal-app/core"; -import { useGridEvents, useViewer, Viewer } from "@pascal-app/viewer"; +import { initSpatialGridSync, sceneRegistry, useScene } from '@pascal-app/core' +import { useGridEvents, useViewer, Viewer } from '@pascal-app/viewer' -import { useFrame, useThree } from "@react-three/fiber"; -import { useEffect, useMemo, useRef } from "react"; -import { Color, MathUtils, Mesh, Object3D, Vector3 } from "three"; +import { useFrame } from '@react-three/fiber' +import { useMemo, useRef } from 'react' +import { MathUtils, type Mesh } 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"; -import { SelectionManager } from "./selection-manager"; +import { color, float, fract, fwidth, mix, positionLocal } from 'three/tsl' +import { MeshBasicNodeMaterial } from 'three/webgpu' +import { ToolManager } from '../tools/tool-manager' +import { ActionMenu } from '../ui/action-menu' +import { SidebarProvider } from '../ui/primitives/sidebar' +import { AppSidebar } from '../ui/sidebar/app-sidebar' +import { CustomCameraControls } from './custom-camera-controls' +import { SelectionManager } from './selection-manager' -initSpatialGridSync(); -useScene.getState().loadScene(); +initSpatialGridSync() +useScene.getState().loadScene() export default function Editor() { return ( @@ -57,18 +38,18 @@ export default function Editor() { - ); + ) } const TestUndo = () => { - const { undo, redo, futureStates, pastStates } = useScene.temporal.getState(); + const { undo, redo, futureStates, pastStates } = useScene.temporal.getState() return (
- ); -}; + ) +} const Grid = ({ cellSize = 0.5, cellThickness = 0.5, - cellColor = "#888888", + cellColor = '#888888', sectionSize = 1, sectionThickness = 1, - sectionColor = "#000000", + sectionColor = '#000000', fadeDistance = 100, fadeStrength = 1, }: { - cellSize?: number; - cellThickness?: number; - cellColor?: string; - sectionSize?: number; - sectionThickness?: number; - sectionColor?: string; - fadeDistance?: number; - fadeStrength?: number; + cellSize?: number + cellThickness?: number + cellColor?: string + sectionSize?: number + sectionThickness?: number + sectionColor?: string + fadeDistance?: number + fadeStrength?: number }) => { const material = useMemo(() => { // 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 // Returns 1 on grid lines, 0 elsewhere const getGrid = (size: number, thickness: number) => { - const r = pos.div(size); - const fw = fwidth(r); + const r = pos.div(size) + const fw = fwidth(r) // 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 const lineX = float(1).sub( grid.x .div(fw.x) .add(1 - thickness) .min(1), - ); + ) const lineY = float(1).sub( grid.y .div(fw.y) .add(1 - thickness) .min(1), - ); + ) // Combine both axes - max gives us lines in both directions - return lineX.max(lineY); - }; + return lineX.max(lineY) + } - const g1 = getGrid(cellSize, cellThickness); - const g2 = getGrid(sectionSize, sectionThickness); + const g1 = getGrid(cellSize, cellThickness) + const g2 = getGrid(sectionSize, sectionThickness) // Distance fade from center - const dist = pos.length(); - const fade = float(1).sub(dist.div(fadeDistance).min(1)).pow(fadeStrength); + const dist = pos.length() + const fade = float(1).sub(dist.div(fadeDistance).min(1)).pow(fadeStrength) // Mix colors based on section grid const gridColor = mix( color(cellColor), color(sectionColor), float(sectionThickness).mul(g2).min(1), - ); + ) // Combined alpha - const alpha = g1.add(g2).mul(fade); - const finalAlpha = mix(alpha.mul(0.75), alpha, g2); + const alpha = g1.add(g2).mul(fade) + const finalAlpha = mix(alpha.mul(0.75), alpha, g2) return new MeshBasicNodeMaterial({ transparent: true, colorNode: gridColor, opacityNode: finalAlpha, depthWrite: false, - }); + }) }, [ cellSize, cellThickness, @@ -165,61 +146,52 @@ const Grid = ({ sectionColor, fadeDistance, fadeStrength, - ]); + ]) - const handlers = useGridEvents(); - const gridRef = useRef(null!); + const handlers = useGridEvents() + const gridRef = useRef(null!) useFrame((_, delta) => { - const currentLevelId = useViewer.getState().selection.levelId; - let targetY = 0; + const currentLevelId = useViewer.getState().selection.levelId + let targetY = 0 if (currentLevelId) { - const levelMesh = sceneRegistry.nodes.get(currentLevelId); + const levelMesh = sceneRegistry.nodes.get(currentLevelId) if (levelMesh) { - targetY = levelMesh.position.y; + targetY = levelMesh.position.y } } - gridRef.current.position.y = MathUtils.lerp( - gridRef.current.position.y, - targetY, - 12 * delta, - ); - }); + gridRef.current.position.y = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta) + }) return ( - + - ); -}; + ) +} const LevelModeSwitcher = () => { - const setLevelMode = useViewer((state) => state.setLevelMode); - const levelMode = useViewer((state) => state.levelMode); + const setLevelMode = useViewer((state) => state.setLevelMode) + const levelMode = useViewer((state) => state.levelMode) return (
- ); -}; + ) +} diff --git a/apps/editor/components/editor/selection-manager.tsx b/apps/editor/components/editor/selection-manager.tsx index f7ee4145..e75d124b 100644 --- a/apps/editor/components/editor/selection-manager.tsx +++ b/apps/editor/components/editor/selection-manager.tsx @@ -1,36 +1,13 @@ import { emitter, - initSpatialGridSync, - ItemNode, + type ItemNode, sceneRegistry, - useRegistry, useScene, - WallNode, + type WallNode, } 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, 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"; +import { useEffect, useRef } from "react"; export const SelectionManager = () => { const selectedItemId = useRef(null); diff --git a/apps/editor/components/tools/item/item-tool.tsx b/apps/editor/components/tools/item/item-tool.tsx index 05a9e087..6e4c0413 100644 --- a/apps/editor/components/tools/item/item-tool.tsx +++ b/apps/editor/components/tools/item/item-tool.tsx @@ -1,20 +1,19 @@ -import useEditor from "@/store/use-editor"; import { emitter, - GridEvent, + type GridEvent, ItemNode, sceneRegistry, - useRegistry, useScene, useSpatialQuery, - WallEvent, - WallNode, -} from "@pascal-app/core"; -import { useViewer } from "@pascal-app/viewer"; -import { useFrame } from "@react-three/fiber"; -import { useEffect, useRef } from "react"; -import { BoxGeometry, Mesh, MeshStandardMaterial, Vector3 } from "three"; -import { resolveLevelId } from "../../../../../packages/core/src/hooks/spatial-grid/spatial-grid-sync"; + type WallEvent, + type WallNode, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useFrame } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { BoxGeometry, type Mesh, type MeshStandardMaterial, Vector3 } from 'three' +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. @@ -23,45 +22,45 @@ import { resolveLevelId } from "../../../../../packages/core/src/hooks/spatial-g */ function snapToGrid(position: number, dimension: number): number { // Check if half the dimension has a 0.25 remainder (odd multiple of 0.5) - const halfDim = dimension / 2; - const needsOffset = Math.abs((halfDim * 2) % 1 - 0.5) < 0.01; - const offset = needsOffset ? 0.25 : 0; + const halfDim = dimension / 2 + const needsOffset = Math.abs(((halfDim * 2) % 1) - 0.5) < 0.01 + const offset = needsOffset ? 0.25 : 0 // 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 = () => { - const cursorRef = useRef(null!); - const draftItem = useRef(null); - const gridPosition = useRef(new Vector3(0, 0, 0)); - const selectedItem = useEditor((state) => state.selectedItem); - const { canPlaceOnFloor, canPlaceOnWall } = useSpatialQuery(); - const isOnWall = useRef(false); + const cursorRef = useRef(null!) + const draftItem = useRef(null) + const gridPosition = useRef(new Vector3(0, 0, 0)) + const selectedItem = useEditor((state) => state.selectedItem) + const { canPlaceOnFloor, canPlaceOnWall } = useSpatialQuery() + const isOnWall = useRef(false) useEffect(() => { if (!selectedItem) { - return; + return } - let currentWallId: string | null = null; + let currentWallId: string | null = null const checkCanPlace = () => { - const currentLevelId = useViewer.getState().selection.levelId; + const currentLevelId = useViewer.getState().selection.levelId if (currentLevelId && draftItem.current) { - let placeable = true; + let placeable = true if (draftItem.current.asset.attachTo) { if (!isOnWall.current || !currentWallId) { - placeable = false; + placeable = false } else { const result = canPlaceOnWall( currentLevelId, - currentWallId as WallNode["id"], + currentWallId as WallNode['id'], gridPosition.current.x, gridPosition.current.y, draftItem.current.asset.dimensions, [draftItem.current.id], - ); - placeable = result.valid; + ) + placeable = result.valid } } else { placeable = canPlaceOnFloor( @@ -70,235 +69,213 @@ export const ItemTool: React.FC = () => { draftItem.current.asset.dimensions, [0, 0, 0], [draftItem.current.id], - ).valid; + ).valid } if (placeable) { - (cursorRef.current.material as MeshStandardMaterial).color.set( - "green", - ); - return true; + ;(cursorRef.current.material as MeshStandardMaterial).color.set('green') + return true } else { - (cursorRef.current.material as MeshStandardMaterial).color.set("red"); - return false; + ;(cursorRef.current.material as MeshStandardMaterial).color.set('red') + return false } } - }; + } const createDraftItem = () => { - const currentLevelId = useViewer.getState().selection.levelId; + const currentLevelId = useViewer.getState().selection.levelId if (!currentLevelId) { - return null; + return null } - useScene.temporal.getState().pause(); + useScene.temporal.getState().pause() draftItem.current = ItemNode.parse({ - position: [ - gridPosition.current.x, - gridPosition.current.y, - gridPosition.current.z, - ], + position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], name: selectedItem.name, asset: selectedItem, - }); - useScene.getState().createNode(draftItem.current, currentLevelId); - checkCanPlace(); - }; - createDraftItem(); + }) + useScene.getState().createNode(draftItem.current, currentLevelId) + checkCanPlace() + } + createDraftItem() 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( snapToGrid(event.position[0], dimX), 0, snapToGrid(event.position[2], dimZ), - ); + ) cursorRef.current.position.set( gridPosition.current.x, event.position[1], gridPosition.current.z, - ); - checkCanPlace(); + ) + checkCanPlace() if (draftItem.current) { - draftItem.current.position = [ - gridPosition.current.x, - 0, - gridPosition.current.z, - ]; + draftItem.current.position = [gridPosition.current.x, 0, gridPosition.current.z] } - }; + } const onGridClick = (event: GridEvent) => { - const currentLevelId = useViewer.getState().selection.levelId; - if (isOnWall.current) return; + const currentLevelId = useViewer.getState().selection.levelId + 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, { position: [gridPosition.current.x, 0, gridPosition.current.z], - }); - draftItem.current = null; + }) + draftItem.current = null - useScene.temporal.getState().pause(); - createDraftItem(); - }; + useScene.temporal.getState().pause() + createDraftItem() + } const onWallEnter = (event: WallEvent) => { if ( useViewer.getState().selection.levelId !== resolveLevelId(event.node, useScene.getState().nodes) ) { - return; + return } if ( - draftItem.current?.asset.attachTo === "wall" || - draftItem.current?.asset.attachTo === "wall-side" + draftItem.current?.asset.attachTo === 'wall' || + draftItem.current?.asset.attachTo === 'wall-side' ) { - event.stopPropagation(); - isOnWall.current = true; - currentWallId = event.node.id; + event.stopPropagation() + isOnWall.current = true + currentWallId = event.node.id gridPosition.current.set( Math.round(event.localPosition[0] * 2) / 2, Math.round(event.localPosition[1] * 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, { - position: [ - gridPosition.current.x, - gridPosition.current.y, - gridPosition.current.z, - ], + position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], parentId: event.node.id, - }); - checkCanPlace(); + }) + checkCanPlace() } - }; + } const onWallLeave = (event: WallEvent) => { - if (!isOnWall.current) return; - isOnWall.current = false; - currentWallId = null; - event.stopPropagation(); - if (!draftItem.current) return; - const currentLevelId = useViewer.getState().selection.levelId; - draftItem.current.parentId = currentLevelId; + if (!isOnWall.current) return + isOnWall.current = false + currentWallId = null + event.stopPropagation() + if (!draftItem.current) return + const currentLevelId = useViewer.getState().selection.levelId + draftItem.current.parentId = currentLevelId useScene.getState().updateNode(draftItem.current.id, { - position: [ - gridPosition.current.x, - gridPosition.current.y, - gridPosition.current.z, - ], + position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], parentId: currentLevelId, - }); - checkCanPlace(); - }; + }) + checkCanPlace() + } const onWallClick = (event: WallEvent) => { - event.stopPropagation(); - if (!isOnWall.current) return; + event.stopPropagation() + if (!isOnWall.current) return - const currentLevelId = useViewer.getState().selection.levelId; - if (!currentLevelId || !draftItem.current || !checkCanPlace()) return; + const currentLevelId = useViewer.getState().selection.levelId + if (!currentLevelId || !draftItem.current || !checkCanPlace()) return - useScene.temporal.getState().resume(); + useScene.temporal.getState().resume() useScene.getState().updateNode(draftItem.current.id, { - position: [ - gridPosition.current.x, - gridPosition.current.y, - gridPosition.current.z, - ], + position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], parentId: event.node.id, - }); - useScene.getState().dirtyNodes.add(event.node.id); - draftItem.current = null; + }) + useScene.getState().dirtyNodes.add(event.node.id) + draftItem.current = null - useScene.temporal.getState().pause(); - createDraftItem(); - checkCanPlace(); - }; + useScene.temporal.getState().pause() + createDraftItem() + checkCanPlace() + } const onWallMove = (event: WallEvent) => { - if (isOnWall.current === false) return; - event.stopPropagation(); - if (!draftItem.current) return; + if (isOnWall.current === false) return + event.stopPropagation() + if (!draftItem.current) return gridPosition.current.set( Math.round(event.localPosition[0] * 2) / 2, Math.round(event.localPosition[1] * 2) / 2, Math.round(event.localPosition[2] * 2) / 2, - ); + ) cursorRef.current.position.set( Math.round(event.position[0] * 2) / 2, Math.round(event.position[1] * 2) / 2, Math.round(event.position[2] * 2) / 2, - ); + ) const { node: { start, end }, - } = event; - const dx = end[0] - start[0]; - const dz = end[1] - start[1]; - const { normal } = event; - const wallAngle = Math.atan2(dx, dz); + } = event + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const { normal } = event + const wallAngle = Math.atan2(dx, dz) - cursorRef.current.rotation.y = wallAngle + Math.PI / 2; - const canPlace = checkCanPlace(); + cursorRef.current.rotation.y = wallAngle + Math.PI / 2 + const canPlace = checkCanPlace() if (draftItem.current && canPlace) { draftItem.current.position = [ gridPosition.current.x, gridPosition.current.y, gridPosition.current.z, - ]; - const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id); + ] + const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id) 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:click", onGridClick); - emitter.on("wall:enter", onWallEnter); - emitter.on("wall:move", onWallMove); - emitter.on("wall:click", onWallClick); - emitter.on("wall:leave", onWallLeave); + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('wall:enter', onWallEnter) + emitter.on('wall:move', onWallMove) + emitter.on('wall:click', onWallClick) + emitter.on('wall:leave', onWallLeave) const setupBoundingBox = () => { const boxGeometry = new BoxGeometry( selectedItem.dimensions[0], selectedItem.dimensions[1], selectedItem.dimensions[2], - ); - boxGeometry.translate(0, selectedItem.dimensions[1] / 2, 0); - cursorRef.current.geometry = boxGeometry; - }; - setupBoundingBox(); + ) + boxGeometry.translate(0, selectedItem.dimensions[1] / 2, 0) + cursorRef.current.geometry = boxGeometry + } + setupBoundingBox() return () => { if (draftItem.current) { - useScene.getState().deleteNode(draftItem.current.id); + useScene.getState().deleteNode(draftItem.current.id) } - useScene.temporal.getState().resume(); - emitter.off("grid:move", onGridMove); - emitter.off("grid:click", onGridClick); - emitter.off("wall:enter", onWallEnter); - emitter.off("wall:leave", onWallLeave); - emitter.off("wall:click", onWallClick); - emitter.off("wall:move", onWallMove); - }; - }, [selectedItem]); + useScene.temporal.getState().resume() + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('wall:enter', onWallEnter) + emitter.off('wall:leave', onWallLeave) + emitter.off('wall:click', onWallClick) + emitter.off('wall:move', onWallMove) + } + }, [selectedItem, canPlaceOnFloor, canPlaceOnWall]) useFrame((_, delta) => { if (draftItem.current && !isOnWall.current) { - const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id); + const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id) if (draftItemMesh) { - draftItemMesh.position.lerp(gridPosition.current, delta * 20); + draftItemMesh.position.lerp(gridPosition.current, delta * 20) } } - }); + }) return ( @@ -307,5 +284,5 @@ export const ItemTool: React.FC = () => { - ); -}; + ) +} diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index a13c6e6e..3087370b 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -1,28 +1,26 @@ -import useEditor, { Phase, Tool } from "@/store/use-editor"; -import { WallTool } from "./wall/wall-tool"; -import { ItemTool } from "./item/item-tool"; +import useEditor, { type Phase, type Tool } from '@/store/use-editor' +import { ItemTool } from './item/item-tool' +import { WallTool } from './wall/wall-tool' const tools: Record>> = { - site: { - }, + site: {}, structure: { wall: WallTool, item: ItemTool, }, furnish: { - item: ItemTool + item: ItemTool, }, -}; +} export const ToolManager: React.FC = () => { - const phase = useEditor((state) => state.phase); - const mode = useEditor((state) => state.mode); - const tool = useEditor((state) => state.tool); + const phase = useEditor((state) => state.phase) + const mode = useEditor((state) => state.mode) + 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 ? : null; -}; \ No newline at end of file + return Component ? : null +} diff --git a/apps/editor/components/tools/wall/wall-tool.tsx b/apps/editor/components/tools/wall/wall-tool.tsx index d16520f1..23998980 100644 --- a/apps/editor/components/tools/wall/wall-tool.tsx +++ b/apps/editor/components/tools/wall/wall-tool.tsx @@ -1,75 +1,62 @@ -import { emitter, GridEvent, useScene, WallNode } from "@pascal-app/core"; -import { useViewer } from "@pascal-app/viewer"; -import { useEffect, useRef } from "react"; -import { Line, Mesh, Vector3 } from "three"; +import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useRef } from 'react' +import { type Line, type Mesh, Vector3 } from 'three' const commitWallDrawing = (start: [number, number], end: [number, number]) => { - const currentLevelId = useViewer.getState().selection.levelId; - const { createNode } = useScene.getState(); + const currentLevelId = useViewer.getState().selection.levelId + 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 = () => { - const cursorRef = useRef(null); - const drawingLineRef = useRef(null!); + const cursorRef = useRef(null) + const drawingLineRef = useRef(null!) useEffect(() => { - let buildingState = 0; - const startingPoint = new Vector3(0, 0, 0); - const endingPoint = new Vector3(0, 0, 0); - let gridPosition: [number, number] = [0, 0]; + let buildingState = 0 + const startingPoint = new Vector3(0, 0, 0) + const endingPoint = new Vector3(0, 0, 0) + let gridPosition: [number, number] = [0, 0] - drawingLineRef.current.geometry.setFromPoints([startingPoint, endingPoint]); + drawingLineRef.current.geometry.setFromPoints([startingPoint, endingPoint]) const onGridMove = (event: GridEvent) => { - if (!cursorRef.current) return; + if (!cursorRef.current) return - gridPosition = [ - Math.round(event.position[0] * 2) / 2, - Math.round(event.position[2] * 2) / 2, - ]; - cursorRef.current.position.set( - gridPosition[0], - event.position[1], - gridPosition[1], - ); + gridPosition = [Math.round(event.position[0] * 2) / 2, Math.round(event.position[2] * 2) / 2] + cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[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([ - startingPoint, - endingPoint, - ]); - }; + drawingLineRef.current.geometry.setFromPoints([startingPoint, endingPoint]) + } const onGridClick = (event: GridEvent) => { if (buildingState === 0) { - startingPoint.set(gridPosition[0], event.position[1], gridPosition[1]); - buildingState = 1; - console.log("starting building at:", startingPoint); - drawingLineRef.current.visible = true; + startingPoint.set(gridPosition[0], event.position[1], gridPosition[1]) + buildingState = 1 + console.log('starting building at:', startingPoint) + drawingLineRef.current.visible = true } else if (buildingState === 1) { - commitWallDrawing( - [startingPoint.x, startingPoint.z], - [endingPoint.x, endingPoint.z], - ); - drawingLineRef.current.visible = false; - buildingState = 0; + commitWallDrawing([startingPoint.x, startingPoint.z], [endingPoint.x, endingPoint.z]) + drawingLineRef.current.visible = false + buildingState = 0 } - }; + } - emitter.on("grid:move", onGridMove); - emitter.on("grid:click", onGridClick); + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) return () => { - emitter.off("grid:move", onGridMove); - emitter.off("grid:click", onGridClick); - }; - }, []); + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + } + }, []) return ( @@ -79,12 +66,7 @@ export const WallTool: React.FC = () => { {/* @ts-ignore */} - + { - ); -}; + ) +} diff --git a/apps/editor/hooks/use-mobile.ts b/apps/editor/hooks/use-mobile.ts index a93d5839..4331d5c5 100644 --- a/apps/editor/hooks/use-mobile.ts +++ b/apps/editor/hooks/use-mobile.ts @@ -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() { - const [isMobile, setIsMobile] = React.useState( - undefined, - ); + const [isMobile, setIsMobile] = React.useState(undefined) React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); - }; - mql.addEventListener("change", onChange); - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); - return () => mql.removeEventListener("change", onChange); - }, []); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener('change', onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener('change', onChange) + }, []) - return !!isMobile; + return !!isMobile } diff --git a/apps/editor/lib/utils.ts b/apps/editor/lib/utils.ts index a5ef1935..d32b0fe6 100644 --- a/apps/editor/lib/utils.ts +++ b/apps/editor/lib/utils.ts @@ -1,6 +1,6 @@ -import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)) } diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index c6c21d70..70f4f47b 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -1,6 +1,6 @@ /** @type {import('next').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 diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index cc37a570..75027e46 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -1,111 +1,106 @@ -"use client"; +'use client' -import { - AssetInput, - BuildingNode, - LevelNode, - useScene, -} from "@pascal-app/core"; -import { useViewer } from "@pascal-app/viewer"; -import { create } from "zustand"; -import { Asset } from "../../../packages/core/src/schema/nodes/item"; +import { type BuildingNode, type LevelNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { create } from 'zustand' +import type { 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) export type StructureTool = - | "wall" - | "room" - | "custom-room" - | "slab" - | "ceiling" - | "roof" - | "column" - | "stair" - | "item" - | "zone"; + | 'wall' + | 'room' + | 'custom-room' + | 'slab' + | 'ceiling' + | 'roof' + | 'column' + | 'stair' + | 'item' + | 'zone' // Furnish mode tools (items and decoration) -export type FurnishTool = "item"; +export type FurnishTool = 'item' // Site mode tools -export type SiteTool = "property-line"; +export type SiteTool = 'property-line' // Catalog categories for furnish mode items export type CatalogCategory = - | "furniture" - | "appliance" - | "bathroom" - | "kitchen" - | "outdoor" - | "window" - | "door"; + | 'furniture' + | 'appliance' + | 'bathroom' + | 'kitchen' + | 'outdoor' + | 'window' + | 'door' // Combined tool type -export type Tool = SiteTool | StructureTool | FurnishTool; +export type Tool = SiteTool | StructureTool | FurnishTool type EditorState = { - phase: Phase; - setPhase: (phase: Phase) => void; - mode: Mode; - setMode: (mode: Mode) => void; - tool: Tool | null; - setTool: (tool: Tool | null) => void; - catalogCategory: CatalogCategory | null; - setCatalogCategory: (category: CatalogCategory | null) => void; - selectedItem: Asset | null; - setSelectedItem: (item: Asset) => void; -}; + phase: Phase + setPhase: (phase: Phase) => void + mode: Mode + setMode: (mode: Mode) => void + tool: Tool | null + setTool: (tool: Tool | null) => void + catalogCategory: CatalogCategory | null + setCatalogCategory: (category: CatalogCategory | null) => void + selectedItem: Asset | null + setSelectedItem: (item: Asset) => void +} const useEditor = create()((set, get) => ({ - phase: "site", + phase: 'site', setPhase: (phase) => { - const currentPhase = get().phase; - if (currentPhase === phase) return; + const currentPhase = get().phase + if (currentPhase === phase) return - set({ phase }); + set({ phase }) - const viewer = useViewer.getState(); - const scene = useScene.getState(); + const viewer = useViewer.getState() + const scene = useScene.getState() switch (phase) { - case "site": + case 'site': // In Site mode, we zoom out and deselect specific levels/buildings - viewer.resetSelection(); - viewer.setLevelMode("stacked"); - break; + viewer.resetSelection() + viewer.setLevelMode('stacked') + break - case "structure": + case 'structure': // In Structure mode, we often want to focus on a specific building/level // Auto-select the first building if none is selected if (!viewer.selection.buildingId) { const firstBuildingId = scene.rootNodeIds.find((id) => { - const node = scene.nodes[id]; - return node?.type === "building" || null; - }); + const node = scene.nodes[id] + return node?.type === 'building' || null + }) if (firstBuildingId) { viewer.setSelection({ - buildingId: firstBuildingId as BuildingNode["id"], - }); - const buildingNode = scene.nodes[firstBuildingId] as BuildingNode; - const firstLevelId = buildingNode.children[0]; + buildingId: firstBuildingId as BuildingNode['id'], + }) + const buildingNode = scene.nodes[firstBuildingId] as BuildingNode + const firstLevelId = buildingNode.children[0] if (firstLevelId) { - viewer.setSelection({ levelId: firstLevelId as LevelNode["id"] }); + viewer.setSelection({ levelId: firstLevelId as LevelNode['id'] }) } } } - viewer.setLevelMode("stacked"); // Better for structure editing - break; + viewer.setLevelMode('stacked') // Better for structure editing + break - case "furnish": + case 'furnish': // Maybe in furnish mode we force "solo" level view to see inside rooms - viewer.setLevelMode("solo"); - break; + viewer.setLevelMode('solo') + break } }, - mode: "select", + mode: 'select', setMode: (mode) => set({ mode }), tool: null, setTool: (tool) => set({ tool }), @@ -113,6 +108,6 @@ const useEditor = create()((set, get) => ({ setCatalogCategory: (category) => set({ catalogCategory: category }), selectedItem: null, setSelectedItem: (item) => set({ selectedItem: item }), -})); +})) -export default useEditor; +export default useEditor diff --git a/biome.jsonc b/biome.jsonc index 9d1df77a..a9ed91cb 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,6 +1,5 @@ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", - "extends": ["ultracite", "ultracite/core", "ultracite/next"], "vcs": { "enabled": true, "clientKind": "git", @@ -66,7 +65,7 @@ "correctness": { "noUnusedVariables": "off", "noUnusedFunctionParameters": "off", - "noUnusedImports": "off", + "noUnusedImports": "error", "useExhaustiveDependencies": "info", "noPrecisionLoss": "off" }, @@ -98,6 +97,10 @@ "packages/**/*.css", "packages/**/*.md", "packages/**/*.mdx", + "apps/**/*.ts", + "apps/**/*.tsx", + "apps/**/*.js", + "apps/**/*.jsx", "!**/node_modules", "!**/.next", "!**/dist", diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index ce76f82e..400dfebe 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,49 +1,49 @@ -import mitt from "mitt"; -import { BuildingNode, ItemNode, WallNode } from "../schema"; -import { AnyNode } from "../schema/types"; +import mitt from 'mitt' +import type { BuildingNode, ItemNode, WallNode } from '../schema' +import type { AnyNode } from '../schema/types' // Base event interfaces export interface GridEvent { - position: [number, number, number]; + position: [number, number, number] } export interface NodeEvent { - node: T; - position: [number, number, number]; - localPosition: [number, number, number]; - normal?: [number, number, number]; - stopPropagation: () => void; + node: T + position: [number, number, number] + localPosition: [number, number, number] + normal?: [number, number, number] + stopPropagation: () => void } -export type WallEvent = NodeEvent; -export type ItemEvent = NodeEvent; -export type BuildingEvent = NodeEvent; +export type WallEvent = NodeEvent +export type ItemEvent = NodeEvent +export type BuildingEvent = NodeEvent // Event suffixes - exported for use in hooks export const eventSuffixes = [ - "click", - "move", - "enter", - "leave", - "pointerdown", - "pointerup", - "context-menu", - "double-click", -] as const; + 'click', + 'move', + 'enter', + 'leave', + 'pointerdown', + 'pointerup', + 'context-menu', + 'double-click', +] as const -export type EventSuffix = (typeof eventSuffixes)[number]; +export type EventSuffix = (typeof eventSuffixes)[number] type NodeEvents = { - [K in `${T}:${EventSuffix}`]: E; -}; + [K in `${T}:${EventSuffix}`]: E +} type GridEvents = { - [K in `grid:${EventSuffix}`]: GridEvent; -}; + [K in `grid:${EventSuffix}`]: GridEvent +} type EditorEvents = GridEvents & - NodeEvents<"wall", WallEvent> & - NodeEvents<"item", ItemEvent> & - NodeEvents<"building", BuildingEvent>; + NodeEvents<'wall', WallEvent> & + NodeEvents<'item', ItemEvent> & + NodeEvents<'building', BuildingEvent> -export const emitter = mitt(); +export const emitter = mitt() diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index 65e0ae93..0ccc6e08 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -1,6 +1,5 @@ -import * as THREE from "three"; - -import { useLayoutEffect } from "react"; +import { useLayoutEffect } from 'react' +import type * as THREE from 'three' export const sceneRegistry = { // Master lookup: ID -> Object3D @@ -15,27 +14,27 @@ export const sceneRegistry = { item: new Set(), slab: new Set(), }, -}; +} export function useRegistry( id: string, type: keyof typeof sceneRegistry.byType, - ref: React.RefObject + ref: React.RefObject, ) { useLayoutEffect(() => { - const obj = ref.current; - if (!obj) return; + const obj = ref.current + if (!obj) return // 1. Add to master map - sceneRegistry.nodes.set(id, obj); + sceneRegistry.nodes.set(id, obj) // 2. Add to type-specific set - sceneRegistry.byType[type].add(id); + sceneRegistry.byType[type].add(id) // 4. Cleanup when component unmounts return () => { - sceneRegistry.nodes.delete(id); - sceneRegistry.byType[type].delete(id); - }; - }, [id, type, ref]); + sceneRegistry.nodes.delete(id) + sceneRegistry.byType[type].delete(id) + } + }, [id, type, ref]) } diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index b3bb9ec2..524f2607 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,64 +1,58 @@ -import { AnyNode, ItemNode, WallNode } from "../../schema"; -import { SpatialGrid } from "./spatial-grid"; -import { WallSpatialGrid } from "./wall-spatial-grid"; +import type { AnyNode, ItemNode, WallNode } from '../../schema' +import { SpatialGrid } from './spatial-grid' +import { WallSpatialGrid } from './wall-spatial-grid' export class SpatialGridManager { - private floorGrids = new Map(); // levelId -> grid - private wallGrids = new Map(); // levelId -> wall grid - private walls = new Map(); // wallId -> wall data (for length calculations) + private floorGrids = new Map() // levelId -> grid + private wallGrids = new Map() // levelId -> wall grid + private walls = new Map() // wallId -> wall data (for length calculations) constructor(private cellSize = 0.5) {} private getFloorGrid(levelId: string): SpatialGrid { if (!this.floorGrids.has(levelId)) { - this.floorGrids.set( - levelId, - new SpatialGrid({ cellSize: this.cellSize }), - ); + this.floorGrids.set(levelId, new SpatialGrid({ cellSize: this.cellSize })) } - return this.floorGrids.get(levelId)!; + return this.floorGrids.get(levelId)! } private getWallGrid(levelId: string): WallSpatialGrid { if (!this.wallGrids.has(levelId)) { - this.wallGrids.set(levelId, new WallSpatialGrid()); + this.wallGrids.set(levelId, new WallSpatialGrid()) } - return this.wallGrids.get(levelId)!; + return this.wallGrids.get(levelId)! } private getWallLength(wallId: string): number { - const wall = this.walls.get(wallId); - if (!wall) return 0; - const dx = wall.end[0] - wall.start[0]; - const dy = wall.end[1] - wall.start[1]; - return Math.sqrt(dx * dx + dy * dy); + const wall = this.walls.get(wallId) + if (!wall) return 0 + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + return Math.sqrt(dx * dx + dy * dy) } private getWallHeight(wallId: string): number { - const wall = this.walls.get(wallId); - return wall?.height ?? 2.5; // Default wall height + const wall = this.walls.get(wallId) + return wall?.height ?? 2.5 // Default wall height } // Called when nodes change handleNodeCreated(node: AnyNode, levelId: string) { - if (node.type === "wall") { - const wall = node as WallNode; - this.walls.set(wall.id, wall); - } else if (node.type === "item") { - const item = node as ItemNode; - if ( - item.asset.attachTo === "wall" || - item.asset.attachTo === "wall-side" - ) { + if (node.type === 'wall') { + const wall = node as WallNode + this.walls.set(wall.id, wall) + } else if (node.type === 'item') { + const item = node as ItemNode + if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { // Wall-attached item - use parentId as the wall ID - const wallId = item.parentId; + const wallId = item.parentId if (wallId && this.walls.has(wallId)) { - const wallLength = this.getWallLength(wallId); + const wallLength = this.getWallLength(wallId) if (wallLength > 0) { - const [width, height] = item.asset.dimensions; - const halfW = width / wallLength / 2; + const [width, height] = item.asset.dimensions + const halfW = width / wallLength / 2 // Calculate t from local X position (position[0] is distance along wall) - const t = item.position[0] / wallLength; + const t = item.position[0] / wallLength // position[1] is the bottom of the item this.getWallGrid(levelId).insert({ itemId: item.id, @@ -67,7 +61,7 @@ export class SpatialGridManager { tEnd: t + halfW, yStart: item.position[1], yEnd: item.position[1] + height, - }); + }) } } } else if (!item.asset.attachTo) { @@ -77,31 +71,28 @@ export class SpatialGridManager { item.position, item.asset.dimensions, item.rotation, - ); + ) } } } handleNodeUpdated(node: AnyNode, levelId: string) { - if (node.type === "wall") { - const wall = node as WallNode; - this.walls.set(wall.id, wall); - } else if (node.type === "item") { - const item = node as ItemNode; - if ( - item.asset.attachTo === "wall" || - item.asset.attachTo === "wall-side" - ) { + if (node.type === 'wall') { + const wall = node as WallNode + this.walls.set(wall.id, wall) + } else if (node.type === 'item') { + const item = node as ItemNode + if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { // Remove old placement and re-insert - this.getWallGrid(levelId).removeByItemId(item.id); - const wallId = item.parentId; + this.getWallGrid(levelId).removeByItemId(item.id) + const wallId = item.parentId if (wallId && this.walls.has(wallId)) { - const wallLength = this.getWallLength(wallId); + const wallLength = this.getWallLength(wallId) if (wallLength > 0) { - const [width, height] = item.asset.dimensions; - const halfW = width / wallLength / 2; + const [width, height] = item.asset.dimensions + const halfW = width / wallLength / 2 // Calculate t from local X position (position[0] is distance along wall) - const t = item.position[0] / wallLength; + const t = item.position[0] / wallLength // position[1] is the bottom of the item this.getWallGrid(levelId).insert({ itemId: item.id, @@ -110,7 +101,7 @@ export class SpatialGridManager { tEnd: t + halfW, yStart: item.position[1], yEnd: item.position[1] + height, - }); + }) } } } else if (!item.asset.attachTo) { @@ -119,22 +110,22 @@ export class SpatialGridManager { item.position, item.asset.dimensions, item.rotation, - ); + ) } } } handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) { - if (nodeType === "wall") { - this.walls.delete(nodeId); + if (nodeType === 'wall') { + this.walls.delete(nodeId) // Remove all items attached to this wall from the spatial grid - const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId); - return removedItemIds; // Caller can use this to delete the items from scene - } else if (nodeType === "item") { - this.getFloorGrid(levelId).remove(nodeId); - this.getWallGrid(levelId).removeByItemId(nodeId); + const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId) + return removedItemIds // Caller can use this to delete the items from scene + } else if (nodeType === 'item') { + this.getFloorGrid(levelId).remove(nodeId) + this.getWallGrid(levelId).removeByItemId(nodeId) } - return []; + return [] } // Query methods @@ -145,8 +136,8 @@ export class SpatialGridManager { rotation: [number, number, number], ignoreIds?: string[], ) { - const grid = this.getFloorGrid(levelId); - return grid.canPlace(position, dimensions, rotation, ignoreIds); + const grid = this.getFloorGrid(levelId) + return grid.canPlace(position, dimensions, rotation, ignoreIds) } /** @@ -166,14 +157,14 @@ export class SpatialGridManager { dimensions: [number, number, number], ignoreIds?: string[], ) { - const wallLength = this.getWallLength(wallId); + const wallLength = this.getWallLength(wallId) if (wallLength === 0) { - return { valid: false, conflictIds: [] }; + return { valid: false, conflictIds: [] } } - const wallHeight = this.getWallHeight(wallId); + const wallHeight = this.getWallHeight(wallId) // Convert local X position to parametric t (0-1) - const tCenter = localX / wallLength; - const [itemWidth, itemHeight] = dimensions; + const tCenter = localX / wallLength + const [itemWidth, itemHeight] = dimensions return this.getWallGrid(levelId).canPlaceOnWall( wallId, wallLength, @@ -183,24 +174,24 @@ export class SpatialGridManager { localY, itemHeight, ignoreIds, - ); + ) } getWallForItem(levelId: string, itemId: string): string | undefined { - return this.getWallGrid(levelId).getWallForItem(itemId); + return this.getWallGrid(levelId).getWallForItem(itemId) } clearLevel(levelId: string) { - this.floorGrids.delete(levelId); - this.wallGrids.delete(levelId); + this.floorGrids.delete(levelId) + this.wallGrids.delete(levelId) } clear() { - this.floorGrids.clear(); - this.wallGrids.clear(); - this.walls.clear(); + this.floorGrids.clear() + this.wallGrids.clear() + this.walls.clear() } } // Singleton instance -export const spatialGridManager = new SpatialGridManager(); +export const spatialGridManager = new SpatialGridManager() diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index 4f658f8c..6668664b 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -1,70 +1,67 @@ -import { AnyNode } from "../../schema"; -import useScene from "../../store/use-scene"; -import { spatialGridManager } from "./spatial-grid-manager"; +import type { AnyNode } from '../../schema' +import useScene from '../../store/use-scene' +import { spatialGridManager } from './spatial-grid-manager' -export function resolveLevelId( - node: AnyNode, - nodes: Record, -): string { +export function resolveLevelId(node: AnyNode, nodes: Record): string { // If the node itself is a level - if (node.type === "level") return node.id; + if (node.type === 'level') return node.id // Walk up parent chain to find level // This assumes you track parentId or can derive it - let current: AnyNode | undefined = node; + let current: AnyNode | undefined = node while (current) { - if (current.type === "level") return current.id; + if (current.type === 'level') return current.id // Find parent (you might need to add parentId to your schema or derive it) if (!current.parentId) { - current = undefined; + current = undefined } else { - current = nodes[current.parentId]; + current = nodes[current.parentId] } } - return "default"; // fallback for orphaned items + return 'default' // fallback for orphaned items } // Call this once at app initialization export function initSpatialGridSync() { - const store = useScene; + const store = useScene // Subscribe to all changes store.subscribe((state, prevState) => { // Detect added nodes for (const [id, node] of Object.entries(state.nodes)) { - if (!prevState.nodes[id as AnyNode["id"]]) { - const levelId = resolveLevelId(node, state.nodes); - spatialGridManager.handleNodeCreated(node, levelId); + if (!prevState.nodes[id as AnyNode['id']]) { + const levelId = resolveLevelId(node, state.nodes) + spatialGridManager.handleNodeCreated(node, levelId) } } // Detect removed nodes for (const [id, node] of Object.entries(prevState.nodes)) { - if (!state.nodes[id as AnyNode["id"]]) { - const levelId = resolveLevelId(node, prevState.nodes); - spatialGridManager.handleNodeDeleted(id, node.type, levelId); + if (!state.nodes[id as AnyNode['id']]) { + const levelId = resolveLevelId(node, prevState.nodes) + spatialGridManager.handleNodeDeleted(id, node.type, levelId) } } // Detect updated nodes (items with position/rotation/parentId changes) for (const [id, node] of Object.entries(state.nodes)) { - const prev = prevState.nodes[id as AnyNode["id"]]; - if (prev && node.type === "item" && prev.type === "item") { + const prev = prevState.nodes[id as AnyNode['id']] + if (prev && node.type === 'item' && prev.type === 'item') { if ( !arraysEqual(node.position, prev.position) || !arraysEqual(node.rotation, prev.rotation) || node.parentId !== prev.parentId ) { - const levelId = resolveLevelId(node, state.nodes); - spatialGridManager.handleNodeUpdated(node, levelId); + const levelId = resolveLevelId(node, state.nodes) + spatialGridManager.handleNodeUpdated(node, levelId) } } } - }); + }) } function arraysEqual(a: number[], b: number[]): boolean { - return a.length === b.length && a.every((v, i) => v === b[i]); + return a.length === b.length && a.every((v, i) => v === b[i]) } diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid.ts b/packages/core/src/hooks/spatial-grid/spatial-grid.ts index 9fe6061e..d23c0340 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid.ts @@ -1,28 +1,25 @@ -type CellKey = `${number},${number}`; +type CellKey = `${number},${number}` interface GridCell { - itemIds: Set; + itemIds: Set } interface SpatialGridConfig { - cellSize: number; // e.g., 0.5 meters = Sims-style half-tile + cellSize: number // e.g., 0.5 meters = Sims-style half-tile } export class SpatialGrid { - private cells = new Map(); - private itemCells = new Map>(); // reverse lookup + private cells = new Map() + private itemCells = new Map>() // reverse lookup constructor(private config: SpatialGridConfig) {} private posToCell(x: number, z: number): [number, number] { - return [ - Math.floor(x / this.config.cellSize), - Math.floor(z / this.config.cellSize), - ]; + return [Math.floor(x / this.config.cellSize), Math.floor(z / this.config.cellSize)] } private cellKey(cx: number, cz: number): CellKey { - return `${cx},${cz}`; + return `${cx},${cz}` } // Get all cells an item occupies based on its AABB @@ -33,34 +30,34 @@ export class SpatialGrid { ): CellKey[] { // Simplified: axis-aligned bounding box // For full rotation support, compute rotated corners - const [x, , z] = position; - const [w, , d] = dimensions; - const yRot = rotation[1]; // Y-axis rotation + const [x, , z] = position + const [w, , d] = dimensions + const yRot = rotation[1] // Y-axis rotation // Compute rotated footprint (simplified for 90° increments) - const cos = Math.abs(Math.cos(yRot)); - const sin = Math.abs(Math.sin(yRot)); - const rotatedW = w * cos + d * sin; - const rotatedD = w * sin + d * cos; + const cos = Math.abs(Math.cos(yRot)) + const sin = Math.abs(Math.sin(yRot)) + const rotatedW = w * cos + d * sin + const rotatedD = w * sin + d * cos - const minX = x - rotatedW / 2; - const maxX = x + rotatedW / 2; - const minZ = z - rotatedD / 2; - const maxZ = z + rotatedD / 2; + const minX = x - rotatedW / 2 + const maxX = x + rotatedW / 2 + const minZ = z - rotatedD / 2 + const maxZ = z + rotatedD / 2 - const [minCx, minCz] = this.posToCell(minX, minZ); + const [minCx, minCz] = this.posToCell(minX, minZ) // Use exclusive upper bound: subtract epsilon so exact boundaries don't overlap // This allows adjacent items (touching but not overlapping) to not conflict - const epsilon = 1e-6; - const [maxCx, maxCz] = this.posToCell(maxX - epsilon, maxZ - epsilon); + const epsilon = 1e-6 + const [maxCx, maxCz] = this.posToCell(maxX - epsilon, maxZ - epsilon) - const keys: CellKey[] = []; + const keys: CellKey[] = [] for (let cx = minCx; cx <= maxCx; cx++) { for (let cz = minCz; cz <= maxCz; cz++) { - keys.push(this.cellKey(cx, cz)); + keys.push(this.cellKey(cx, cz)) } } - return keys; + return keys } // Register an item @@ -70,33 +67,33 @@ export class SpatialGrid { dimensions: [number, number, number], rotation: [number, number, number], ) { - const cellKeys = this.getItemCells(position, dimensions, rotation); + const cellKeys = this.getItemCells(position, dimensions, rotation) - this.itemCells.set(itemId, new Set(cellKeys)); + this.itemCells.set(itemId, new Set(cellKeys)) for (const key of cellKeys) { if (!this.cells.has(key)) { - this.cells.set(key, { itemIds: new Set() }); + this.cells.set(key, { itemIds: new Set() }) } - this.cells.get(key)!.itemIds.add(itemId); + this.cells.get(key)!.itemIds.add(itemId) } } // Remove an item remove(itemId: string) { - const cellKeys = this.itemCells.get(itemId); - if (!cellKeys) return; + const cellKeys = this.itemCells.get(itemId) + if (!cellKeys) return for (const key of cellKeys) { - const cell = this.cells.get(key); + const cell = this.cells.get(key) if (cell) { - cell.itemIds.delete(itemId); + cell.itemIds.delete(itemId) if (cell.itemIds.size === 0) { - this.cells.delete(key); + this.cells.delete(key) } } } - this.itemCells.delete(itemId); + this.itemCells.delete(itemId) } // Update = remove + insert @@ -106,8 +103,8 @@ export class SpatialGrid { dimensions: [number, number, number], rotation: [number, number, number], ) { - this.remove(itemId); - this.insert(itemId, position, dimensions, rotation); + this.remove(itemId) + this.insert(itemId, position, dimensions, rotation) } // Query: is this placement valid? @@ -117,16 +114,16 @@ export class SpatialGrid { rotation: [number, number, number], ignoreIds: string[] = [], ): { valid: boolean; conflictIds: string[] } { - const cellKeys = this.getItemCells(position, dimensions, rotation); - const ignoreSet = new Set(ignoreIds); - const conflicts = new Set(); + const cellKeys = this.getItemCells(position, dimensions, rotation) + const ignoreSet = new Set(ignoreIds) + const conflicts = new Set() for (const key of cellKeys) { - const cell = this.cells.get(key); + const cell = this.cells.get(key) if (cell) { for (const id of cell.itemIds) { if (!ignoreSet.has(id)) { - conflicts.add(id); + conflicts.add(id) } } } @@ -135,29 +132,29 @@ export class SpatialGrid { return { valid: conflicts.size === 0, conflictIds: [...conflicts], - }; + } } // Query: get all items near a point (for snapping, selection, etc.) queryRadius(x: number, z: number, radius: number): string[] { - const cellRadius = Math.ceil(radius / this.config.cellSize); - const [cx, cz] = this.posToCell(x, z); - const found = new Set(); + const cellRadius = Math.ceil(radius / this.config.cellSize) + const [cx, cz] = this.posToCell(x, z) + const found = new Set() for (let dx = -cellRadius; dx <= cellRadius; dx++) { for (let dz = -cellRadius; dz <= cellRadius; dz++) { - const cell = this.cells.get(this.cellKey(cx + dx, cz + dz)); + const cell = this.cells.get(this.cellKey(cx + dx, cz + dz)) if (cell) { for (const id of cell.itemIds) { - found.add(id); + found.add(id) } } } } - return [...found]; + return [...found] } getItemCount(): number { - return this.itemCells.size; + return this.itemCells.size } } diff --git a/packages/core/src/hooks/spatial-grid/use-spatial-query.ts b/packages/core/src/hooks/spatial-grid/use-spatial-query.ts index fe1e1ade..2d16d9b7 100644 --- a/packages/core/src/hooks/spatial-grid/use-spatial-query.ts +++ b/packages/core/src/hooks/spatial-grid/use-spatial-query.ts @@ -1,31 +1,25 @@ -import { useCallback } from "react"; -import { LevelNode, WallNode } from "../../schema"; -import { spatialGridManager } from "./spatial-grid-manager"; +import { useCallback } from 'react' +import type { LevelNode, WallNode } from '../../schema' +import { spatialGridManager } from './spatial-grid-manager' export function useSpatialQuery() { const canPlaceOnFloor = useCallback( ( - levelId: LevelNode["id"], + levelId: LevelNode['id'], position: [number, number, number], dimensions: [number, number, number], rotation: [number, number, number], ignoreIds?: string[], ) => { - return spatialGridManager.canPlaceOnFloor( - levelId, - position, - dimensions, - rotation, - ignoreIds, - ); + return spatialGridManager.canPlaceOnFloor(levelId, position, dimensions, rotation, ignoreIds) }, [], - ); + ) const canPlaceOnWall = useCallback( ( - levelId: LevelNode["id"], - wallId: WallNode["id"], + levelId: LevelNode['id'], + wallId: WallNode['id'], localX: number, localY: number, dimensions: [number, number, number], @@ -38,10 +32,10 @@ export function useSpatialQuery() { localY, dimensions, ignoreIds, - ); + ) }, [], - ); + ) - return { canPlaceOnFloor, canPlaceOnWall }; + return { canPlaceOnFloor, canPlaceOnWall } } diff --git a/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts b/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts index af421c0b..deb60c6d 100644 --- a/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts +++ b/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts @@ -1,15 +1,15 @@ interface WallItemPlacement { - itemId: string; - wallId: string; - tStart: number; // 0-1 parametric position along wall - tEnd: number; - yStart: number; // height range - yEnd: number; + itemId: string + wallId: string + tStart: number // 0-1 parametric position along wall + tEnd: number + yStart: number // height range + yEnd: number } export class WallSpatialGrid { - private wallItems = new Map(); // wallId -> placements - private itemToWall = new Map(); // itemId -> wallId (reverse lookup) + private wallItems = new Map() // wallId -> placements + private itemToWall = new Map() // itemId -> wallId (reverse lookup) canPlaceOnWall( wallId: string, @@ -21,82 +21,82 @@ export class WallSpatialGrid { itemHeight: number, ignoreIds: string[] = [], ): { valid: boolean; conflictIds: string[] } { - const halfW = itemWidth / wallLength / 2; - const tStart = tCenter - halfW; - const tEnd = tCenter + halfW; + const halfW = itemWidth / wallLength / 2 + const tStart = tCenter - halfW + const tEnd = tCenter + halfW // yBottom is the bottom of the item, so yEnd = yBottom + itemHeight - const yStart = yBottom; - const yEnd = yBottom + itemHeight; + const yStart = yBottom + const yEnd = yBottom + itemHeight // Check wall boundaries if (tStart < 0 || tEnd > 1 || yStart < 0 || yEnd > wallHeight) { - return { valid: false, conflictIds: [] }; + return { valid: false, conflictIds: [] } } - const existing = this.wallItems.get(wallId) ?? []; - const ignoreSet = new Set(ignoreIds); - const conflicts: string[] = []; + const existing = this.wallItems.get(wallId) ?? [] + const ignoreSet = new Set(ignoreIds) + const conflicts: string[] = [] for (const placement of existing) { - if (ignoreSet.has(placement.itemId)) continue; + if (ignoreSet.has(placement.itemId)) continue - const tOverlap = tStart < placement.tEnd && tEnd > placement.tStart; - const yOverlap = yStart < placement.yEnd && yEnd > placement.yStart; + const tOverlap = tStart < placement.tEnd && tEnd > placement.tStart + const yOverlap = yStart < placement.yEnd && yEnd > placement.yStart if (tOverlap && yOverlap) { - conflicts.push(placement.itemId); + conflicts.push(placement.itemId) } } - return { valid: conflicts.length === 0, conflictIds: conflicts }; + return { valid: conflicts.length === 0, conflictIds: conflicts } } insert(placement: WallItemPlacement) { - const { wallId, itemId } = placement; + const { wallId, itemId } = placement if (!this.wallItems.has(wallId)) { - this.wallItems.set(wallId, []); + this.wallItems.set(wallId, []) } - this.wallItems.get(wallId)!.push(placement); - this.itemToWall.set(itemId, wallId); + this.wallItems.get(wallId)!.push(placement) + this.itemToWall.set(itemId, wallId) } remove(wallId: string, itemId: string) { - const items = this.wallItems.get(wallId); + const items = this.wallItems.get(wallId) if (items) { - const idx = items.findIndex((p) => p.itemId === itemId); - if (idx !== -1) items.splice(idx, 1); + const idx = items.findIndex((p) => p.itemId === itemId) + if (idx !== -1) items.splice(idx, 1) } - this.itemToWall.delete(itemId); + this.itemToWall.delete(itemId) } removeByItemId(itemId: string) { - const wallId = this.itemToWall.get(itemId); + const wallId = this.itemToWall.get(itemId) if (wallId) { - this.remove(wallId, itemId); + this.remove(wallId, itemId) } } // Useful for when a wall is deleted - remove all items on it removeWall(wallId: string): string[] { - const items = this.wallItems.get(wallId) ?? []; - const removedIds = items.map((p) => p.itemId); + const items = this.wallItems.get(wallId) ?? [] + const removedIds = items.map((p) => p.itemId) for (const itemId of removedIds) { - this.itemToWall.delete(itemId); + this.itemToWall.delete(itemId) } - this.wallItems.delete(wallId); + this.wallItems.delete(wallId) - return removedIds; // Return removed item IDs in case you need to delete them from scene + return removedIds // Return removed item IDs in case you need to delete them from scene } // Get which wall an item is on getWallForItem(itemId: string): string | undefined { - return this.itemToWall.get(itemId); + return this.itemToWall.get(itemId) } clear() { - this.wallItems.clear(); - this.itemToWall.clear(); + this.wallItems.clear() + this.itemToWall.clear() } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 83c4ed8a..bc0df323 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,27 +1,23 @@ // Store -export { default as useScene } from "./store/use-scene"; +export type { + EventSuffix, + GridEvent, + ItemEvent, + NodeEvent, + WallEvent, +} from './events/bus' +// Events +export { emitter, eventSuffixes } from './events/bus' // Hooks export { sceneRegistry, useRegistry, -} from "./hooks/scene-registry/scene-registry"; - -export { useSpatialQuery } from "./hooks/spatial-grid/use-spatial-query"; -export { initSpatialGridSync } from "./hooks/spatial-grid/spatial-grid-sync"; - -// Systems -export { WallSystem } from "./systems/wall/wall-system"; - -// Events -export { emitter, eventSuffixes } from "./events/bus"; -export type { - ItemEvent, - WallEvent, - NodeEvent, - GridEvent, - EventSuffix, -} from "./events/bus"; - +} from './hooks/scene-registry/scene-registry' +export { initSpatialGridSync } from './hooks/spatial-grid/spatial-grid-sync' +export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' // Schema -export * from "./schema"; +export * from './schema' +export { default as useScene } from './store/use-scene' +// Systems +export { WallSystem } from './systems/wall/wall-system' diff --git a/packages/core/src/schema/base.ts b/packages/core/src/schema/base.ts index 154a5fa4..0e24ddf2 100644 --- a/packages/core/src/schema/base.ts +++ b/packages/core/src/schema/base.ts @@ -1,33 +1,32 @@ -import { customAlphabet } from "nanoid"; -import { z } from "zod"; -import { CameraSchema } from "./camera"; +import { customAlphabet } from 'nanoid' +import { z } from 'zod' +import { CameraSchema } from './camera' -const customId = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 16); +const customId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16) /** * Material preset name reference * @example 'white', 'brick', 'wood', 'glass', 'preview-valid' */ -export const Material = z.string().optional(); +export const Material = z.string().optional() export const generateId = (prefix: T): `${T}_${string}` => - `${prefix}_${customId()}` as `${T}_${string}`; + `${prefix}_${customId()}` as `${T}_${string}` export const objectId = (prefix: T) => { - const schema = z.templateLiteral([`${prefix}_`, z.string()]); + const schema = z.templateLiteral([`${prefix}_`, z.string()]) - return schema.default(() => generateId(prefix) as z.infer); -}; -export const nodeType = (type: T) => - z.literal(type).default(type); + return schema.default(() => generateId(prefix) as z.infer) +} +export const nodeType = (type: T) => z.literal(type).default(type) export const BaseNode = z.object({ - object: z.literal("node").default("node"), + object: z.literal('node').default('node'), id: z.string(), // objectId('node'), @Aymericr: Thing is if we specify objectId here, when using BaseNode.extend, TS complains that the id is not assignable to the more specific type in the extended node - type: nodeType("node"), + type: nodeType('node'), name: z.string().optional(), parentId: z.string().nullable().default(null), visible: z.boolean().optional().default(true), camera: CameraSchema.optional(), metadata: z.json().optional().default({}), -}); +}) -export type BaseNode = z.infer; +export type BaseNode = z.infer diff --git a/packages/core/src/schema/camera.ts b/packages/core/src/schema/camera.ts index adf74f26..c691eaba 100644 --- a/packages/core/src/schema/camera.ts +++ b/packages/core/src/schema/camera.ts @@ -1,13 +1,13 @@ -import { z } from "zod"; +import { z } from 'zod' -const Vector3Schema = z.tuple([z.number(), z.number(), z.number()]); +const Vector3Schema = z.tuple([z.number(), z.number(), z.number()]) export const CameraSchema = z.object({ position: Vector3Schema, target: Vector3Schema, - mode: z.enum(["perspective", "orthographic"]).default("perspective"), + mode: z.enum(['perspective', 'orthographic']).default('perspective'), fov: z.number().optional(), // For perspective zoom: z.number().optional(), // For orthographic -}); +}) -export type Camera = z.infer; +export type Camera = z.infer diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 19197c75..4ab32e74 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -1,20 +1,17 @@ // Base -export { BaseNode, generateId, objectId, nodeType, Material } from "./base"; - -// Nodes -export { SiteNode } from "./nodes/site"; -export { BuildingNode } from "./nodes/building"; -export { LevelNode } from "./nodes/level"; -export { WallNode } from "./nodes/wall"; -export { ItemNode } from "./nodes/item"; -export type { AssetInput } from "./nodes/item"; - -// Union types -export { AnyNode } from "./types"; -export type { AnyNodeType, AnyNodeId } from "./types"; - +export { BaseNode, generateId, Material, nodeType, objectId } from './base' // Camera -export { CameraSchema } from "./camera"; +export { CameraSchema } from './camera' +export { BuildingNode } from './nodes/building' +export type { AssetInput } from './nodes/item' +export { ItemNode } from './nodes/item' +export { LevelNode } from './nodes/level' +// Nodes +export { SiteNode } from './nodes/site' +export { WallNode } from './nodes/wall' +export type { AnyNodeId, AnyNodeType } from './types' +// Union types +export { AnyNode } from './types' // Zones -export type { Zone, ZonePolygon } from "./zone"; +export type { Zone, ZonePolygon } from './zone' diff --git a/packages/core/src/schema/nodes/building.ts b/packages/core/src/schema/nodes/building.ts index 1accb4f3..c4a86f5d 100644 --- a/packages/core/src/schema/nodes/building.ts +++ b/packages/core/src/schema/nodes/building.ts @@ -1,11 +1,11 @@ -import dedent from "dedent"; -import { z } from "zod"; -import { BaseNode, nodeType, objectId } from "../base"; -import { LevelNode } from "./level"; +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { LevelNode } from './level' export const BuildingNode = BaseNode.extend({ - id: objectId("building"), - type: nodeType("building"), + id: objectId('building'), + type: nodeType('building'), children: z.array(LevelNode.shape.id).default([]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), @@ -15,7 +15,7 @@ export const BuildingNode = BaseNode.extend({ - position: position in site coordinate system - rotation: rotation in site coordinate system - children: array of level nodes (each level is a tree of floor and wall nodes) - ` -); + `, +) -export type BuildingNode = z.infer; +export type BuildingNode = z.infer diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index 6c993346..e6e0122b 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -1,6 +1,6 @@ -import dedent from "dedent"; -import { z } from "zod"; -import { BaseNode, nodeType, objectId } from "../base"; +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' const assetSchema = z.object({ id: z.string(), @@ -9,22 +9,22 @@ const assetSchema = z.object({ thumbnail: z.string(), src: z.string(), dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d] - attachTo: z.enum(["wall", "wall-side", "ceiling"]).optional(), + attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(), // These are "Corrective" transforms to normalize the GLB offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), -}); +}) -export type AssetInput = z.input; -export type Asset = z.infer; +export type AssetInput = z.input +export type Asset = z.infer export const ItemNode = BaseNode.extend({ - id: objectId("item"), - type: nodeType("item"), + id: objectId('item'), + type: nodeType('item'), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), - side: z.enum(["front", "back"]).optional(), + side: z.enum(['front', 'back']).optional(), // Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side") wallId: z.string().optional(), @@ -42,6 +42,6 @@ export const ItemNode = BaseNode.extend({ - offset: corrective position offset for the model - rotation: corrective rotation for the model - scale: corrective scale for the model -`); +`) -export type ItemNode = z.infer; +export type ItemNode = z.infer diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index 24ec738c..1e641669 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -1,11 +1,11 @@ -import dedent from "dedent"; -import { z } from "zod"; -import { BaseNode, nodeType, objectId } from "../base"; -import { WallNode } from "./wall"; +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { WallNode } from './wall' export const LevelNode = BaseNode.extend({ - id: objectId("level"), - type: nodeType("level"), + id: objectId('level'), + type: nodeType('level'), children: z.array(WallNode.shape.id).default([]), // Specific props level: z.number().default(0), @@ -14,7 +14,7 @@ export const LevelNode = BaseNode.extend({ Level node - used to represent a level in the building - children: array of floor, wall, ceiling, roof, item nodes - level: level number - ` -); + `, +) -export type LevelNode = z.infer; +export type LevelNode = z.infer diff --git a/packages/core/src/schema/nodes/site.ts b/packages/core/src/schema/nodes/site.ts index 3f4cc312..b621c741 100644 --- a/packages/core/src/schema/nodes/site.ts +++ b/packages/core/src/schema/nodes/site.ts @@ -1,16 +1,16 @@ // lib/scenegraph/schema/nodes/site.ts -import dedent from "dedent"; -import { z } from "zod"; -import { BaseNode, nodeType, objectId } from "../base"; -import { BuildingNode } from "./building"; -import { ItemNode } from "./item"; +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { BuildingNode } from './building' +import { ItemNode } from './item' // 2D Polygon const PropertyLineData = z.object({ - type: z.literal("polygon"), + type: z.literal('polygon'), points: z.array(z.tuple([z.number(), z.number()])), -}); +}) // 3D Polygon/Mesh // const TerrainData = z.object({ @@ -19,11 +19,11 @@ const PropertyLineData = z.object({ // }) export const SiteNode = BaseNode.extend({ - id: objectId("site"), - type: nodeType("site"), + id: objectId('site'), + type: nodeType('site'), // Specific props polygon: PropertyLineData.optional().default({ - type: "polygon", + type: 'polygon', // Default 30x30 square matching GRID_SIZE points: [ [0, 0], @@ -34,14 +34,14 @@ export const SiteNode = BaseNode.extend({ }), // terrain: TerrainData, children: z - .array(z.discriminatedUnion("type", [BuildingNode, ItemNode])) + .array(z.discriminatedUnion('type', [BuildingNode, ItemNode])) .default([BuildingNode.parse({})]), }).describe( dedent` Site node - used to represent a site - polygon: polygon data - children: array of building and item nodes - ` -); + `, +) -export type SiteNode = z.infer; +export type SiteNode = z.infer diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 4bcdf41f..6ce0ddef 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -1,14 +1,14 @@ -import dedent from "dedent"; -import { z } from "zod"; -import { BaseNode, nodeType, objectId } from "../base"; -import { ItemNode } from "./item"; +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { ItemNode } from './item' // import { DoorNode } from "./door"; // import { ItemNode } from "./item"; // import { WindowNode } from "./window"; export const WallNode = BaseNode.extend({ - id: objectId("wall"), - type: nodeType("wall"), + id: objectId('wall'), + type: nodeType('wall'), children: z.array(ItemNode.shape.id).default([]), // Specific props thickness: z.number().optional(), @@ -24,6 +24,6 @@ export const WallNode = BaseNode.extend({ - start: start point of the wall in level coordinate system - end: end point of the wall in level coordinate system - size: size of the wall in grid units - ` -); -export type WallNode = z.infer; + `, +) +export type WallNode = z.infer diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index b8b48434..1d8b80b1 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -1,18 +1,18 @@ -import z from "zod"; -import { BuildingNode } from "./nodes/building"; -import { ItemNode } from "./nodes/item"; -import { LevelNode } from "./nodes/level"; -import { SiteNode } from "./nodes/site"; -import { WallNode } from "./nodes/wall"; +import z from 'zod' +import { BuildingNode } from './nodes/building' +import { ItemNode } from './nodes/item' +import { LevelNode } from './nodes/level' +import { SiteNode } from './nodes/site' +import { WallNode } from './nodes/wall' -export const AnyNode = z.discriminatedUnion("type", [ +export const AnyNode = z.discriminatedUnion('type', [ SiteNode, BuildingNode, LevelNode, WallNode, ItemNode, -]); +]) -export type AnyNode = z.infer; -export type AnyNodeType = AnyNode["type"]; -export type AnyNodeId = AnyNode["id"]; +export type AnyNode = z.infer +export type AnyNodeType = AnyNode['type'] +export type AnyNodeId = AnyNode['id'] diff --git a/packages/core/src/schema/zone.ts b/packages/core/src/schema/zone.ts index bf6abf30..f79b6b34 100644 --- a/packages/core/src/schema/zone.ts +++ b/packages/core/src/schema/zone.ts @@ -1,21 +1,21 @@ -import dedent from "dedent"; -import { z } from "zod"; -import { objectId } from "./base"; -import { LevelNode } from "./nodes/level"; +import dedent from 'dedent' +import { z } from 'zod' +import { objectId } from './base' +import { LevelNode } from './nodes/level' // Polygon boundary for zone area - array of [x, z] coordinates -export const ZonePolygon = z.array(z.tuple([z.number(), z.number()])); +export const ZonePolygon = z.array(z.tuple([z.number(), z.number()])) export const ZoneSchema = z .object({ - id: objectId("zone"), - object: z.literal("zone").default("zone"), + id: objectId('zone'), + object: z.literal('zone').default('zone'), levelId: LevelNode.shape.id, // Required - must be attached to a level name: z.string(), // Polygon boundary - array of [x, z] coordinates defining the zone polygon: ZonePolygon, // Visual styling - color: z.string().default("#3b82f6"), // Default blue + color: z.string().default('#3b82f6'), // Default blue metadata: z.json().optional().default({}), }) .describe( @@ -29,7 +29,7 @@ export const ZoneSchema = z - color: hex color for visual styling - metadata: zone metadata (optional) `, - ); + ) -export type Zone = z.infer; -export type ZonePolygon = z.infer; +export type Zone = z.infer +export type ZonePolygon = z.infer diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 73b42a45..a2c7b410 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -1,5 +1,5 @@ -import { AnyNode, AnyNodeId } from "../../schema"; -import { SceneState } from "../use-scene"; +import type { AnyNode, AnyNodeId } from '../../schema' +import type { SceneState } from '../use-scene' export const createNodesAction = ( set: (fn: (state: SceneState) => Partial) => void, @@ -7,145 +7,140 @@ export const createNodesAction = ( ops: { node: AnyNode; parentId?: AnyNodeId }[], ) => { set((state) => { - const nextNodes = { ...state.nodes }; - const nextRootIds = [...state.rootNodeIds]; + const nextNodes = { ...state.nodes } + const nextRootIds = [...state.rootNodeIds] for (const { node, parentId } of ops) { // 1. Assign parentId to the child (Safe because BaseNode has parentId) const newNode = { ...node, parentId: parentId ?? null, - }; + } - nextNodes[newNode.id] = newNode; + nextNodes[newNode.id] = newNode // 2. Update the Parent's children list if (parentId && nextNodes[parentId]) { - const parent = nextNodes[parentId]; + const parent = nextNodes[parentId] // Type Guard: Check if the parent node is a container that supports children - if ("children" in parent && Array.isArray(parent.children)) { + if ('children' in parent && Array.isArray(parent.children)) { nextNodes[parentId] = { ...parent, // Use Set to prevent duplicate IDs if createNode is called twice - children: Array.from( - new Set([...parent.children, newNode.id]), - ) as any, // We don't verify child types here - }; + children: Array.from(new Set([...parent.children, newNode.id])) as any, // We don't verify child types here + } } } else if (!parentId) { // 3. Handle Root nodes if (!nextRootIds.includes(newNode.id)) { - nextRootIds.push(newNode.id); + nextRootIds.push(newNode.id) } } } - return { nodes: nextNodes, rootNodeIds: nextRootIds }; - }); + return { nodes: nextNodes, rootNodeIds: nextRootIds } + }) // 4. System Sync ops.forEach(({ node, parentId }) => { - get().markDirty(node.id); - if (parentId) get().markDirty(parentId); - }); -}; + get().markDirty(node.id) + if (parentId) get().markDirty(parentId) + }) +} export const updateNodesAction = ( set: (fn: (state: SceneState) => Partial) => void, get: () => SceneState, updates: { id: AnyNodeId; data: Partial }[], ) => { - const parentsToUpdate = new Set(); + const parentsToUpdate = new Set() set((state) => { - const nextNodes = { ...state.nodes }; + const nextNodes = { ...state.nodes } for (const { id, data } of updates) { - const currentNode = nextNodes[id]; - if (!currentNode) continue; + const currentNode = nextNodes[id] + if (!currentNode) continue // Handle Reparenting Logic - if ( - data.parentId !== undefined && - data.parentId !== currentNode.parentId - ) { + if (data.parentId !== undefined && data.parentId !== currentNode.parentId) { // 1. Remove from old parent if (currentNode.parentId && nextNodes[currentNode.parentId]) { - const oldParent = nextNodes[currentNode.parentId] as AnyContainerNode; + const oldParent = nextNodes[currentNode.parentId] as AnyContainerNode nextNodes[oldParent.id] = { ...oldParent, children: oldParent.children.filter((childId) => childId !== id), - }; - parentsToUpdate.add(oldParent.id); + } + parentsToUpdate.add(oldParent.id) } // 2. Add to new parent if (data.parentId && nextNodes[data.parentId]) { - const newParent = nextNodes[data.parentId] as AnyContainerNode; + const newParent = nextNodes[data.parentId] as AnyContainerNode nextNodes[newParent.id] = { ...newParent, children: Array.from(new Set([...newParent.children, id])), - }; - parentsToUpdate.add(newParent.id); + } + parentsToUpdate.add(newParent.id) } } // Apply the update - nextNodes[id] = { ...nextNodes[id], ...data }; + nextNodes[id] = { ...nextNodes[id], ...data } } - return { nodes: nextNodes }; - }); + return { nodes: nextNodes } + }) // Mark dirty - updates.forEach((u) => get().markDirty(u.id)); - parentsToUpdate.forEach((pId) => get().markDirty(pId)); -}; + updates.forEach((u) => get().markDirty(u.id)) + parentsToUpdate.forEach((pId) => get().markDirty(pId)) +} export const deleteNodesAction = ( set: (fn: (state: SceneState) => Partial) => void, get: () => SceneState, ids: AnyNodeId[], ) => { - const parentsToMarkDirty = new Set(); + const parentsToMarkDirty = new Set() set((state) => { - const nextNodes = { ...state.nodes }; - let nextRootIds = [...state.rootNodeIds]; + const nextNodes = { ...state.nodes } + let nextRootIds = [...state.rootNodeIds] for (const id of ids) { - const node = nextNodes[id]; - if (!node) continue; + const node = nextNodes[id] + if (!node) continue // 1. Remove reference from Parent if (node.parentId && nextNodes[node.parentId]) { - const parent = nextNodes[node.parentId] as AnyContainerNode; + const parent = nextNodes[node.parentId] as AnyContainerNode if (parent.children) { nextNodes[parent.id] = { ...parent, children: parent.children.filter((cid) => cid !== id), - }; - parentsToMarkDirty.add(parent.id); + } + parentsToMarkDirty.add(parent.id) } } // 2. Remove from Root list - nextRootIds = nextRootIds.filter((rid) => rid !== id); + nextRootIds = nextRootIds.filter((rid) => rid !== id) // 3. Delete the node itself - delete nextNodes[id]; + delete nextNodes[id] // Inside the deleteNodes loop - if ("children" in node && node.children.length > 0) { + if ('children' in node && node.children.length > 0) { // Recursively delete all children first - get().deleteNodes(node.children); + get().deleteNodes(node.children) } } - return { nodes: nextNodes, rootNodeIds: nextRootIds }; - }); + return { nodes: nextNodes, rootNodeIds: nextRootIds } + }) // Notify systems that the parent has changed (e.g. Wall needs to fill a window hole) - parentsToMarkDirty.forEach((pId) => get().markDirty(pId)); -}; + parentsToMarkDirty.forEach((pId) => get().markDirty(pId)) +} diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index fc0bd186..a4bb0c38 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -1,37 +1,37 @@ -"use client"; +'use client' -import { create } from "zustand"; -import { BuildingNode, ItemNode } from "../schema"; -import { LevelNode } from "../schema/nodes/level"; -import { WallNode } from "../schema/nodes/wall"; -import { AnyNode, AnyNodeId } from "../schema/types"; -import { temporal } from "zundo"; -import * as nodeActions from "./actions/node-actions"; +import { temporal } from 'zundo' +import { create } from 'zustand' +import { BuildingNode, ItemNode } from '../schema' +import { LevelNode } from '../schema/nodes/level' +import { WallNode } from '../schema/nodes/wall' +import type { AnyNode, AnyNodeId } from '../schema/types' +import * as nodeActions from './actions/node-actions' export type SceneState = { // 1. The Data: A flat dictionary of all nodes - nodes: Record; + nodes: Record // 2. The Root: Which nodes are at the top level? - rootNodeIds: AnyNodeId[]; + rootNodeIds: AnyNodeId[] // 3. The "Dirty" Set: For the Wall/Physics systems - dirtyNodes: Set; + dirtyNodes: Set // Actions - loadScene: () => void; - markDirty: (id: AnyNodeId) => void; - clearDirty: (id: AnyNodeId) => void; + loadScene: () => void + markDirty: (id: AnyNodeId) => void + clearDirty: (id: AnyNodeId) => void - createNode: (node: AnyNode, parentId?: AnyNodeId) => void; - createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void; + createNode: (node: AnyNode, parentId?: AnyNodeId) => void + createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void - updateNode: (id: AnyNodeId, data: Partial) => void; - updateNodes: (updates: { id: AnyNodeId; data: Partial }[]) => void; + updateNode: (id: AnyNodeId, data: Partial) => void + updateNodes: (updates: { id: AnyNodeId; data: Partial }[]) => void - deleteNode: (id: AnyNodeId) => void; - deleteNodes: (ids: AnyNodeId[]) => void; -}; + deleteNode: (id: AnyNodeId) => void + deleteNodes: (ids: AnyNodeId[]) => void +} // type PartializedStoreState = Pick; @@ -50,70 +50,70 @@ const useScene = create()( loadScene: () => { const building = BuildingNode.parse({ children: [], - }); + }) const level0 = LevelNode.parse({ level: 0, children: [], - }); + }) const level1 = LevelNode.parse({ level: 1, children: [], - }); + }) const level2 = LevelNode.parse({ level: 2, children: [], - }); + }) const wall0 = WallNode.parse({ start: [0, 0], end: [5, 0], children: [], parentId: level0.id, - }); + }) const wall1 = WallNode.parse({ start: [0, 0], end: [0, 5], children: [], parentId: level0.id, - }); + }) const wall2 = WallNode.parse({ start: [5, 5], end: [0, 5], children: [], parentId: level0.id, - }); + }) const wall3 = WallNode.parse({ start: [5, 5], end: [5, 0], children: [], parentId: level1.id, - }); + }) const window1 = ItemNode.parse({ - type: "item", - name: "Window", + type: 'item', + name: 'Window', position: [2.5, 0.5, 0], parentId: wall3.id, asset: { - id: "window-round", - name: "Round Window", - thumbnail: "/items/window-small/thumbnail.png", - category: "windows", - attachTo: "wall", - src: "/items/window-small/model.glb", + id: 'window-round', + name: 'Round Window', + thumbnail: '/items/window-small/thumbnail.png', + category: 'windows', + attachTo: 'wall', + src: '/items/window-small/model.glb', }, - }); + }) - wall3.children.push(window1.id); + wall3.children.push(window1.id) - level0.children.push(wall0.id, wall1.id, wall2.id); - level1.children.push(wall3.id); + level0.children.push(wall0.id, wall1.id, wall2.id) + level1.children.push(wall3.id) - building.children.push(level0.id, level1.id, level2.id); + building.children.push(level0.id, level1.id, level2.id) // Define all nodes flat const nodes: Record = { @@ -126,34 +126,31 @@ const useScene = create()( [wall2.id]: wall2, [wall3.id]: wall3, [window1.id]: window1, - }; + } // Root nodes are the levels - const rootNodeIds = [building.id]; + const rootNodeIds = [building.id] - get().dirtyNodes.add(wall0.id); - get().dirtyNodes.add(wall1.id); - get().dirtyNodes.add(wall2.id); - get().dirtyNodes.add(wall3.id); - set({ nodes, rootNodeIds }); + get().dirtyNodes.add(wall0.id) + get().dirtyNodes.add(wall1.id) + get().dirtyNodes.add(wall2.id) + get().dirtyNodes.add(wall3.id) + set({ nodes, rootNodeIds }) }, markDirty: (id) => { - get().dirtyNodes.add(id); + get().dirtyNodes.add(id) }, clearDirty: (id) => { - get().dirtyNodes.delete(id); + get().dirtyNodes.delete(id) }, createNodes: (ops) => nodeActions.createNodesAction(set, get, ops), - createNode: (node, parentId) => - nodeActions.createNodesAction(set, get, [{ node, parentId }]), + createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]), - updateNodes: (updates) => - nodeActions.updateNodesAction(set, get, updates), - updateNode: (id, data) => - nodeActions.updateNodesAction(set, get, [{ id, data }]), + updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates), + updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]), // --- DELETE --- @@ -163,26 +160,26 @@ const useScene = create()( }), { partialize: (state) => { - const { nodes, rootNodeIds } = state; // Only track nodes and rootNodeIds in history - return { nodes, rootNodeIds }; + const { nodes, rootNodeIds } = state // Only track nodes and rootNodeIds in history + return { nodes, rootNodeIds } }, limit: 50, // Limit to last 50 actions }, ), -); +) -export default useScene; +export default useScene // Subscribe to the temporal store (Undo/Redo events) useScene.temporal.subscribe((state, prevState) => { // Check if we just jumped in time (Undo/Redo) // If the 'nodes' object changed but it wasn't a normal 'set' - const currentNodes = useScene.getState().nodes; + const currentNodes = useScene.getState().nodes // Trigger a full scene re-validation Object.values(currentNodes).forEach((node) => { - if (node.type === "wall") { - useScene.getState().markDirty(node.id); + if (node.type === 'wall') { + useScene.getState().markDirty(node.id) } - }); -}); + }) +}) diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index bf3a12f4..8e1780b7 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -1,19 +1,19 @@ -import { useFrame } from "@react-three/fiber"; -import * as THREE from "three"; -import { sceneRegistry } from "../../hooks/scene-registry/scene-registry"; -import { AnyNode, WallNode } from "../../schema"; -import useScene from "../../store/use-scene"; +import { useFrame } from '@react-three/fiber' +import * as THREE from 'three' +import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' +import type { AnyNode, WallNode } from '../../schema' +import useScene from '../../store/use-scene' export const WallSystem = () => { - const { nodes, dirtyNodes, clearDirty } = useScene(); + const { nodes, dirtyNodes, clearDirty } = useScene() useFrame(() => { - if (dirtyNodes.size === 0) return; + if (dirtyNodes.size === 0) return dirtyNodes.forEach((id) => { - const node = nodes[id]; - if (!node) return; - const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh; + const node = nodes[id] + if (!node) return + const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh // 1. If a window is dirty, we actually need to redraw its PARENT wall // if ((node.type === 'window' || node.type === 'door') && node.parentId) { @@ -22,117 +22,111 @@ export const WallSystem = () => { // } // 2. If the wall itself is dirty - if (node.type === "wall" && mesh) { - updateWallGeometry(id); + if (node.type === 'wall' && mesh) { + updateWallGeometry(id) } - clearDirty(id); // Reset for next frame - }); - }); + clearDirty(id) // Reset for next frame + }) + }) - return null; -}; + return null +} // Optimization: Logic moved to a vanilla function so it can be called // by the Editor or the System without React overhead function updateWallGeometry(wallId: string) { - const node = useScene.getState().nodes[wallId as WallNode["id"]]; - if (!node) return; - if (node.type !== "wall") return; + const node = useScene.getState().nodes[wallId as WallNode['id']] + if (!node) return + if (node.type !== 'wall') return - const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh; - if (!mesh) return; + const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh + if (!mesh) return - const childrenIds = node.children || []; + const childrenIds = node.children || [] const childrenNodes = childrenIds .map((childId) => useScene.getState().nodes[childId]) - .filter((n): n is AnyNode => n !== undefined); + .filter((n): n is AnyNode => n !== undefined) // Generate visual geometry with holes - const newGeo = generateExtrudedWall(node, childrenNodes); + const newGeo = generateExtrudedWall(node, childrenNodes) - mesh.geometry.dispose(); - mesh.geometry = newGeo; + mesh.geometry.dispose() + mesh.geometry = newGeo // Update collision mesh with solid geometry (no holes) - const collisionMesh = mesh.getObjectByName("collision-mesh") as THREE.Mesh; + const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh if (collisionMesh) { - const collisionGeo = generateExtrudedWall(node, []); // No children = no holes - collisionMesh.geometry.dispose(); - collisionMesh.geometry = collisionGeo; + const collisionGeo = generateExtrudedWall(node, []) // No children = no holes + collisionMesh.geometry.dispose() + collisionMesh.geometry = collisionGeo } - mesh.position.set(node.start[0], 0, node.start[1]); + mesh.position.set(node.start[0], 0, node.start[1]) // Rotate mesh to look at 'end' point - const angle = Math.atan2( - node.end[1] - node.start[1], - node.end[0] - node.start[0], - ); - mesh.rotation.y = -angle; + const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]) + mesh.rotation.y = -angle } -export function generateExtrudedWall( - wallNode: WallNode, - childrenNodes: AnyNode[], -) { +export function generateExtrudedWall(wallNode: WallNode, childrenNodes: AnyNode[]) { // 1. Calculate Wall Dimensions - const start = new THREE.Vector2(wallNode.start[0], wallNode.start[1]); - const end = new THREE.Vector2(wallNode.end[0], wallNode.end[1]); - const length = start.distanceTo(end); - const height = wallNode.height || 2.5; - const thickness = wallNode.thickness || 0.1; + const start = new THREE.Vector2(wallNode.start[0], wallNode.start[1]) + const end = new THREE.Vector2(wallNode.end[0], wallNode.end[1]) + const length = start.distanceTo(end) + const height = wallNode.height || 2.5 + const thickness = wallNode.thickness || 0.1 // 2. Create the Main Wall Shape (a rectangle in 2D) // We draw this on the XY plane, where X is "along the wall" and Y is "height" - const shape = new THREE.Shape(); - shape.moveTo(0, 0); - shape.lineTo(length, 0); - shape.lineTo(length, height); - shape.lineTo(0, height); - shape.closePath(); + const shape = new THREE.Shape() + shape.moveTo(0, 0) + shape.lineTo(length, 0) + shape.lineTo(length, height) + shape.lineTo(0, height) + shape.closePath() // 3. Process Openings (Holes) // Compute wall's transform info for converting world coords to wall-local coords - const wallStart: [number, number] = [wallNode.start[0], wallNode.start[1]]; + const wallStart: [number, number] = [wallNode.start[0], wallNode.start[1]] const wallAngle = Math.atan2( wallNode.end[1] - wallNode.start[1], wallNode.end[0] - wallNode.start[0], - ); + ) // Get the wall mesh's world Y position (from level offset) - const wallMesh = sceneRegistry.nodes.get(wallNode.id) as THREE.Mesh; - const wallWorldY = wallMesh?.getWorldPosition(new THREE.Vector3()).y ?? 0; + const wallMesh = sceneRegistry.nodes.get(wallNode.id) as THREE.Mesh + const wallWorldY = wallMesh?.getWorldPosition(new THREE.Vector3()).y ?? 0 childrenNodes.forEach((child) => { // Only process items that are intended to be wall cutouts - if (child.type !== "item") return; + if (child.type !== 'item') return - const childMesh = sceneRegistry.nodes.get(child.id); + const childMesh = sceneRegistry.nodes.get(child.id) if (!childMesh) { - return; + return } - const cutoutMesh = childMesh.getObjectByName("cutout") as THREE.Mesh; - if (!cutoutMesh) return; + const cutoutMesh = childMesh.getObjectByName('cutout') as THREE.Mesh + if (!cutoutMesh) return - const holePath = createPathFromCutout(cutoutMesh, wallStart, wallAngle, wallWorldY); + const holePath = createPathFromCutout(cutoutMesh, wallStart, wallAngle, wallWorldY) if (holePath) { - shape.holes.push(holePath); + shape.holes.push(holePath) } - }); + }) // 4. Extrude the Shape into 3D const geometry = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false, - }); + }) // 5. Pivot Alignment // Center the geometry thickness so the "start/end" line is in the middle of the wall - geometry.translate(0, 0, -thickness / 2); + geometry.translate(0, 0, -thickness / 2) - return geometry; + return geometry } /** @@ -150,72 +144,72 @@ function createPathFromCutout( wallAngle: number, wallWorldY: number, ): THREE.Path | null { - const geometry = cutoutMesh.geometry; - if (!geometry) return null; + const geometry = cutoutMesh.geometry + if (!geometry) return null - const positions = geometry.attributes.position; - if (!positions) return null; + const positions = geometry.attributes.position + if (!positions) return null // Update world matrix to get correct world positions - cutoutMesh.updateWorldMatrix(true, false); + cutoutMesh.updateWorldMatrix(true, false) // Collect unique vertices (buffer geometry has duplicates for triangulation) - const uniquePoints: THREE.Vector2[] = []; - const seen = new Set(); - const v3 = new THREE.Vector3(); + const uniquePoints: THREE.Vector2[] = [] + const seen = new Set() + const v3 = new THREE.Vector3() // Precompute sin/cos for rotation - const cosAngle = Math.cos(-wallAngle); - const sinAngle = Math.sin(-wallAngle); + const cosAngle = Math.cos(-wallAngle) + const sinAngle = Math.sin(-wallAngle) for (let i = 0; i < positions.count; i++) { - v3.fromBufferAttribute(positions, i); + v3.fromBufferAttribute(positions, i) // Transform to world space - v3.applyMatrix4(cutoutMesh.matrixWorld); + v3.applyMatrix4(cutoutMesh.matrixWorld) // Transform from world space to wall-local space: // 1. Translate so wall start is at origin (in XZ plane) - const worldX = v3.x - wallStart[0]; - const worldZ = v3.z - wallStart[1]; + const worldX = v3.x - wallStart[0] + const worldZ = v3.z - wallStart[1] // 2. Rotate around Y axis to align wall with local X axis // The wall shape is drawn on XY plane, so we need: // - localX = distance along wall // - localY = height relative to wall's Y position - const localX = worldX * cosAngle - worldZ * sinAngle; - const localY = v3.y - wallWorldY; // Subtract wall's world Y to get local height + const localX = worldX * cosAngle - worldZ * sinAngle + const localY = v3.y - wallWorldY // Subtract wall's world Y to get local height // Create a key for deduplication (with small tolerance) - const key = `${localX.toFixed(4)},${localY.toFixed(4)}`; + const key = `${localX.toFixed(4)},${localY.toFixed(4)}` if (!seen.has(key)) { - seen.add(key); - uniquePoints.push(new THREE.Vector2(localX, localY)); + seen.add(key) + uniquePoints.push(new THREE.Vector2(localX, localY)) } } - if (uniquePoints.length < 3) return null; + if (uniquePoints.length < 3) return null // Sort points in counter-clockwise order around centroid - const centroid = new THREE.Vector2(0, 0); + const centroid = new THREE.Vector2(0, 0) for (const p of uniquePoints) { - centroid.add(p); + centroid.add(p) } - centroid.divideScalar(uniquePoints.length); + centroid.divideScalar(uniquePoints.length) uniquePoints.sort((a, b) => { - const angleA = Math.atan2(a.y - centroid.y, a.x - centroid.x); - const angleB = Math.atan2(b.y - centroid.y, b.x - centroid.x); - return angleA - angleB; - }); + const angleA = Math.atan2(a.y - centroid.y, a.x - centroid.x) + const angleB = Math.atan2(b.y - centroid.y, b.x - centroid.x) + return angleA - angleB + }) // Create the path - const path = new THREE.Path(); - path.moveTo(uniquePoints[0]?.x || 0, uniquePoints[0]?.y || 0); + const path = new THREE.Path() + path.moveTo(uniquePoints[0]?.x || 0, uniquePoints[0]?.y || 0) for (let i = 1; i < uniquePoints.length; i++) { - path.lineTo(uniquePoints[i]?.x || 0, uniquePoints[i]?.y || 0); + path.lineTo(uniquePoints[i]?.x || 0, uniquePoints[i]?.y || 0) } - path.closePath(); + path.closePath() - return path; + return path } diff --git a/packages/eslint-config/base.js b/packages/eslint-config/base.js index 09d316ef..407c426f 100644 --- a/packages/eslint-config/base.js +++ b/packages/eslint-config/base.js @@ -1,8 +1,8 @@ -import js from "@eslint/js"; -import eslintConfigPrettier from "eslint-config-prettier"; -import turboPlugin from "eslint-plugin-turbo"; -import tseslint from "typescript-eslint"; -import onlyWarn from "eslint-plugin-only-warn"; +import js from '@eslint/js' +import eslintConfigPrettier from 'eslint-config-prettier' +import onlyWarn from 'eslint-plugin-only-warn' +import turboPlugin from 'eslint-plugin-turbo' +import tseslint from 'typescript-eslint' /** * A shared ESLint configuration for the repository. @@ -18,7 +18,7 @@ export const config = [ turbo: turboPlugin, }, rules: { - "turbo/no-undeclared-env-vars": "warn", + 'turbo/no-undeclared-env-vars': 'warn', }, }, { @@ -27,6 +27,6 @@ export const config = [ }, }, { - ignores: ["dist/**"], + ignores: ['dist/**'], }, -]; +] diff --git a/packages/eslint-config/next.js b/packages/eslint-config/next.js index 4df088ac..a6f5ab14 100644 --- a/packages/eslint-config/next.js +++ b/packages/eslint-config/next.js @@ -1,12 +1,12 @@ -import js from "@eslint/js"; -import { globalIgnores } from "eslint/config"; -import eslintConfigPrettier from "eslint-config-prettier"; -import tseslint from "typescript-eslint"; -import pluginReactHooks from "eslint-plugin-react-hooks"; -import pluginReact from "eslint-plugin-react"; -import globals from "globals"; -import pluginNext from "@next/eslint-plugin-next"; -import { config as baseConfig } from "./base.js"; +import js from '@eslint/js' +import pluginNext from '@next/eslint-plugin-next' +import { globalIgnores } from 'eslint/config' +import eslintConfigPrettier from 'eslint-config-prettier' +import pluginReact from 'eslint-plugin-react' +import pluginReactHooks from 'eslint-plugin-react-hooks' +import globals from 'globals' +import tseslint from 'typescript-eslint' +import { config as baseConfig } from './base.js' /** * A custom ESLint configuration for libraries that use Next.js. @@ -20,10 +20,10 @@ export const nextJsConfig = [ ...tseslint.configs.recommended, globalIgnores([ // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", + '.next/**', + 'out/**', + 'build/**', + 'next-env.d.ts', ]), { ...pluginReact.configs.flat.recommended, @@ -36,22 +36,22 @@ export const nextJsConfig = [ }, { plugins: { - "@next/next": pluginNext, + '@next/next': pluginNext, }, rules: { ...pluginNext.configs.recommended.rules, - ...pluginNext.configs["core-web-vitals"].rules, + ...pluginNext.configs['core-web-vitals'].rules, }, }, { plugins: { - "react-hooks": pluginReactHooks, + 'react-hooks': pluginReactHooks, }, - settings: { react: { version: "detect" } }, + settings: { react: { version: 'detect' } }, rules: { ...pluginReactHooks.configs.recommended.rules, // React scope no longer necessary with new JSX transform. - "react/react-in-jsx-scope": "off", + 'react/react-in-jsx-scope': 'off', }, }, -]; +] diff --git a/packages/eslint-config/react-internal.js b/packages/eslint-config/react-internal.js index daeccba2..f4b98d3b 100644 --- a/packages/eslint-config/react-internal.js +++ b/packages/eslint-config/react-internal.js @@ -1,10 +1,10 @@ -import js from "@eslint/js"; -import eslintConfigPrettier from "eslint-config-prettier"; -import tseslint from "typescript-eslint"; -import pluginReactHooks from "eslint-plugin-react-hooks"; -import pluginReact from "eslint-plugin-react"; -import globals from "globals"; -import { config as baseConfig } from "./base.js"; +import js from '@eslint/js' +import eslintConfigPrettier from 'eslint-config-prettier' +import pluginReact from 'eslint-plugin-react' +import pluginReactHooks from 'eslint-plugin-react-hooks' +import globals from 'globals' +import tseslint from 'typescript-eslint' +import { config as baseConfig } from './base.js' /** * A custom ESLint configuration for libraries that use React. @@ -27,13 +27,13 @@ export const config = [ }, { plugins: { - "react-hooks": pluginReactHooks, + 'react-hooks': pluginReactHooks, }, - settings: { react: { version: "detect" } }, + settings: { react: { version: 'detect' } }, rules: { ...pluginReactHooks.configs.recommended.rules, // React scope no longer necessary with new JSX transform. - "react/react-in-jsx-scope": "off", + 'react/react-in-jsx-scope': 'off', }, }, -]; +] diff --git a/packages/ui/src/button.tsx b/packages/ui/src/button.tsx index 78e5420c..858dd860 100644 --- a/packages/ui/src/button.tsx +++ b/packages/ui/src/button.tsx @@ -1,20 +1,17 @@ -"use client"; +'use client' -import { ReactNode } from "react"; +import type { ReactNode } from 'react' interface ButtonProps { - children: ReactNode; - className?: string; - appName: string; + children: ReactNode + className?: string + appName: string } export const Button = ({ children, className, appName }: ButtonProps) => { return ( - - ); -}; + ) +} diff --git a/packages/ui/src/card.tsx b/packages/ui/src/card.tsx index 7b988937..c06b9615 100644 --- a/packages/ui/src/card.tsx +++ b/packages/ui/src/card.tsx @@ -1,4 +1,4 @@ -import { type JSX } from "react"; +import type { JSX } from 'react' export function Card({ className, @@ -6,10 +6,10 @@ export function Card({ children, href, }: { - className?: string; - title: string; - children: React.ReactNode; - href: string; + className?: string + title: string + children: React.ReactNode + href: string }): JSX.Element { return (

{children}

- ); + ) } diff --git a/packages/ui/src/code.tsx b/packages/ui/src/code.tsx index f7cbd22a..087b4b32 100644 --- a/packages/ui/src/code.tsx +++ b/packages/ui/src/code.tsx @@ -1,11 +1,11 @@ -import { type JSX } from "react"; +import type { JSX } from 'react' export function Code({ children, className, }: { - children: React.ReactNode; - className?: string; + children: React.ReactNode + className?: string }): JSX.Element { - return {children}; + return {children} } diff --git a/packages/viewer/src/components/renderers/building/building-renderer.tsx b/packages/viewer/src/components/renderers/building/building-renderer.tsx index 52161f69..3e835feb 100644 --- a/packages/viewer/src/components/renderers/building/building-renderer.tsx +++ b/packages/viewer/src/components/renderers/building/building-renderer.tsx @@ -1,19 +1,19 @@ -import { BuildingNode, useRegistry } from "@pascal-app/core"; -import { useRef } from "react"; -import { Group } from "three"; -import { NodeRenderer } from "../node-renderer"; -import { useNodeEvents } from "../../../hooks/use-node-events"; +import { type BuildingNode, useRegistry } from '@pascal-app/core' +import { useRef } from 'react' +import type { Group } from 'three' +import { useNodeEvents } from '../../../hooks/use-node-events' +import { NodeRenderer } from '../node-renderer' export const BuildingRenderer = ({ node }: { node: BuildingNode }) => { - const ref = useRef(null!); + const ref = useRef(null!) - useRegistry(node.id, node.type, ref); - const handlers = useNodeEvents(node, "building"); + useRegistry(node.id, node.type, ref) + const handlers = useNodeEvents(node, 'building') return ( {node.children.map((childId) => ( ))} - ); -}; + ) +} diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 7cf91d80..e0f09d12 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -1,21 +1,14 @@ -import { - AnyNodeId, - emitter, - ItemNode, - useRegistry, - useScene, -} from "@pascal-app/core"; -import { Clone } from "@react-three/drei/core/Clone"; -import { useGLTF } from "@react-three/drei/core/Gltf"; -import { ThreeEvent } from "@react-three/fiber/dist/declarations/src/core/events"; -import { Suspense, useCallback, useEffect, useRef } from "react"; -import { Group } from "three"; -import { useNodeEvents } from "../../../hooks/use-node-events"; +import { type AnyNodeId, type ItemNode, useRegistry, useScene } from '@pascal-app/core' +import { Clone } from '@react-three/drei/core/Clone' +import { useGLTF } from '@react-three/drei/core/Gltf' +import { Suspense, useEffect, useRef } from 'react' +import type { Group } from 'three' +import { useNodeEvents } from '../../../hooks/use-node-events' export const ItemRenderer = ({ node }: { node: ItemNode }) => { - const ref = useRef(null!); + const ref = useRef(null!) - useRegistry(node.id, node.type, ref); + useRegistry(node.id, node.type, ref) return ( @@ -23,22 +16,22 @@ export const ItemRenderer = ({ 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) { - nodes.cutout.visible = false; + nodes.cutout.visible = false } - const handlers = useNodeEvents(node, "item"); + const handlers = useNodeEvents(node, 'item') useEffect(() => { - if (!node.parentId) return; - useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId); - }, []); + if (!node.parentId) return + useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId) + }, [node.parentId]) return ( { rotation={node.asset.rotation} {...handlers} /> - ); -}; + ) +} diff --git a/packages/viewer/src/components/renderers/level/level-renderer.tsx b/packages/viewer/src/components/renderers/level/level-renderer.tsx index 980c7018..0ee20ed9 100644 --- a/packages/viewer/src/components/renderers/level/level-renderer.tsx +++ b/packages/viewer/src/components/renderers/level/level-renderer.tsx @@ -1,12 +1,12 @@ -import { LevelNode, useRegistry } from "@pascal-app/core"; -import { useRef } from "react"; -import { Group } from "three"; -import { NodeRenderer } from "../node-renderer"; +import { type LevelNode, useRegistry } from '@pascal-app/core' +import { useRef } from 'react' +import type { Group } from 'three' +import { NodeRenderer } from '../node-renderer' export const LevelRenderer = ({ node }: { node: LevelNode }) => { - const ref = useRef(null!); + const ref = useRef(null!) - useRegistry(node.id, node.type, ref); + useRegistry(node.id, node.type, ref) return ( @@ -18,5 +18,5 @@ export const LevelRenderer = ({ node }: { node: LevelNode }) => { ))} - ); -}; + ) +} diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 57f80e2e..21e1d8c9 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -1,22 +1,22 @@ -"use client"; +'use client' -import { AnyNode, useScene } from "@pascal-app/core"; -import { ItemRenderer } from "./item/item-renderer"; -import { LevelRenderer } from "./level/level-renderer"; -import { WallRenderer } from "./wall/wall-renderer"; -import { BuildingRenderer } from "./building/building-renderer"; +import { type AnyNode, useScene } from '@pascal-app/core' +import { BuildingRenderer } from './building/building-renderer' +import { ItemRenderer } from './item/item-renderer' +import { LevelRenderer } from './level/level-renderer' +import { WallRenderer } from './wall/wall-renderer' -export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode["id"] }) => { - const node = useScene((state) => state.nodes[nodeId]); +export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { + const node = useScene((state) => state.nodes[nodeId]) - if (!node) return null; + if (!node) return null return ( <> - {node.type === "building" && } - {node.type === "level" && } - {node.type === "item" && } - {node.type === "wall" && } + {node.type === 'building' && } + {node.type === 'level' && } + {node.type === 'item' && } + {node.type === 'wall' && } - ); -}; + ) +} diff --git a/packages/viewer/src/components/renderers/scene-renderer.tsx b/packages/viewer/src/components/renderers/scene-renderer.tsx index 569d0a4e..abbc9570 100644 --- a/packages/viewer/src/components/renderers/scene-renderer.tsx +++ b/packages/viewer/src/components/renderers/scene-renderer.tsx @@ -1,12 +1,10 @@ -"use client"; +'use client' -import { useScene } from "@pascal-app/core"; -import { NodeRenderer } from "./node-renderer"; +import { useScene } from '@pascal-app/core' +import { NodeRenderer } from './node-renderer' export const SceneRenderer = () => { - const rootNodes = useScene((state) => state.rootNodeIds); + const rootNodes = useScene((state) => state.rootNodeIds) - return rootNodes.map((nodeId) => ( - - )); -}; + return rootNodes.map((nodeId) => ) +} diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx index f6f978a5..d5d51ae6 100644 --- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx +++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx @@ -1,15 +1,15 @@ -import { useRegistry, WallNode } from "@pascal-app/core"; -import { useRef } from "react"; -import { Mesh } from "three"; -import { NodeRenderer } from "../node-renderer"; -import { useNodeEvents } from "../../../hooks/use-node-events"; +import { useRegistry, type WallNode } from '@pascal-app/core' +import { useRef } from 'react' +import type { Mesh } from 'three' +import { useNodeEvents } from '../../../hooks/use-node-events' +import { NodeRenderer } from '../node-renderer' export const WallRenderer = ({ node }: { node: WallNode }) => { - const ref = useRef(null!); + const ref = useRef(null!) - useRegistry(node.id, "wall", ref); + useRegistry(node.id, 'wall', ref) - const handlers = useNodeEvents(node, "wall"); + const handlers = useNodeEvents(node, 'wall') return ( @@ -24,5 +24,5 @@ export const WallRenderer = ({ node }: { node: WallNode }) => { ))} - ); -}; + ) +} diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 2cc75cae..78045cb7 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,38 +1,36 @@ -"use client"; +'use client' -import { Bvh, Environment, OrbitControls } from "@react-three/drei"; -import { Canvas, ThreeToJSXElements } from "@react-three/fiber"; +import { WallSystem } from '@pascal-app/core' +import { Bvh, Environment } from '@react-three/drei' +import { Canvas, extend, type ThreeToJSXElements } from '@react-three/fiber' +import * as THREE from 'three/webgpu' +import { LevelSystem } from '../../systems/level/level-system' +import { SceneRenderer } from '../renderers/scene-renderer' +import PostProcessing from './post-processing' -import { WallSystem } from "@pascal-app/core"; -import { extend } from "@react-three/fiber"; -import * as THREE from "three/webgpu"; -import { SceneRenderer } from "../renderers/scene-renderer"; -import { LevelSystem } from "../../systems/level/level-system"; -import PostProcessing from "./post-processing"; - -declare module "@react-three/fiber" { +declare module '@react-three/fiber' { interface ThreeElements extends ThreeToJSXElements {} } -extend(THREE as any); +extend(THREE as any) interface ViewerProps { - children?: React.ReactNode; + children?: React.ReactNode } const Viewer: React.FC = ({ children }) => { return ( { - const renderer = new THREE.WebGPURenderer(props as any); - await renderer.init(); - return renderer; + const renderer = new THREE.WebGPURenderer(props as any) + await renderer.init() + return renderer }} shadows camera={{ position: [50, 50, 50], fov: 50 }} > - + @@ -46,7 +44,7 @@ const Viewer: React.FC = ({ children }) => { {children} - ); -}; + ) +} -export default Viewer; +export default Viewer diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 1ee0e189..811ef60f 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -1,99 +1,93 @@ -import { useFrame, useThree } from "@react-three/fiber"; -import { useEffect, useRef } from "react"; -import { Color, Object3D } from "three"; -import { oscSine, pass, time, uniform } from "three/tsl"; -import { outline } from "three/addons/tsl/display/OutlineNode.js"; -import { PostProcessing, WebGPURenderer } from "three/webgpu"; -import useViewer from "../../store/use-viewer"; +import { useFrame, useThree } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { Color } from 'three' +import { outline } from 'three/addons/tsl/display/OutlineNode.js' +import { oscSine, pass, time, uniform } from 'three/tsl' +import { PostProcessing, type WebGPURenderer } from 'three/webgpu' +import useViewer from '../../store/use-viewer' const PostProcessingPasses = ({}) => { - const { gl: renderer, scene, camera } = useThree(); - const postProcessingRef = useRef(null); + const { gl: renderer, scene, camera } = useThree() + const postProcessingRef = useRef(null) useEffect(() => { if (!renderer || !scene || !camera) { - return; + return } - const scenePass = pass(scene, camera); + const scenePass = pass(scene, camera) function generateSelectedOutlinePass() { - const edgeStrength = uniform(3); - const edgeGlow = uniform(0); - const edgeThickness = uniform(1); - const visibleEdgeColor = uniform(new Color(0xffffff)); - const hiddenEdgeColor = uniform(new Color(0xf3ff47)); + const edgeStrength = uniform(3) + const edgeGlow = uniform(0) + const edgeThickness = uniform(1) + const visibleEdgeColor = uniform(new Color(0xffffff)) + const hiddenEdgeColor = uniform(new Color(0xf3ff47)) const outlinePass = outline(scene, camera, { selectedObjects: useViewer.getState().outliner.selectedObjects, edgeGlow, edgeThickness, - }); - const { visibleEdge, hiddenEdge } = outlinePass; + }) + const { visibleEdge, hiddenEdge } = outlinePass const outlineColor = visibleEdge .mul(visibleEdgeColor) .add(hiddenEdge.mul(hiddenEdgeColor)) - .mul(edgeStrength); + .mul(edgeStrength) - return outlineColor; + return outlineColor } function generateHoverOutlinePass() { - const edgeStrength = uniform(5); - const edgeGlow = uniform(0.5); - const edgeThickness = uniform(1.5); - const pulsePeriod = uniform(3); - const visibleEdgeColor = uniform(new Color(0x00aaff)); - const hiddenEdgeColor = uniform(new Color(0xf3ff47)); + const edgeStrength = uniform(5) + const edgeGlow = uniform(0.5) + const edgeThickness = uniform(1.5) + const pulsePeriod = uniform(3) + const visibleEdgeColor = uniform(new Color(0x00aaff)) + const hiddenEdgeColor = uniform(new Color(0xf3ff47)) const outlinePass = outline(scene, camera, { selectedObjects: useViewer.getState().outliner.hoveredObjects, edgeGlow, edgeThickness, - }); - const { visibleEdge, hiddenEdge } = outlinePass; + }) + const { visibleEdge, hiddenEdge } = outlinePass - const period = time.div(pulsePeriod).mul(2); - const osc = oscSine(period).mul(0.5).add(0.5); // osc [ 0.5, 1.0 ] + const period = time.div(pulsePeriod).mul(2) + const osc = oscSine(period).mul(0.5).add(0.5) // osc [ 0.5, 1.0 ] const outlineColor = visibleEdge .mul(visibleEdgeColor) .add(hiddenEdge.mul(hiddenEdgeColor)) - .mul(edgeStrength); - const outlinePulse = pulsePeriod - .greaterThan(0) - .select(outlineColor.mul(osc), outlineColor); - return outlinePulse; + .mul(edgeStrength) + const outlinePulse = pulsePeriod.greaterThan(0).select(outlineColor.mul(osc), outlineColor) + return outlinePulse } // Setup post-processing - const postProcessing = new PostProcessing( - renderer as unknown as WebGPURenderer, - ); + const postProcessing = new PostProcessing(renderer as unknown as WebGPURenderer) - const selectedOutlinePass = generateSelectedOutlinePass(); - const hoverOutlinePass = generateHoverOutlinePass(); + const selectedOutlinePass = generateSelectedOutlinePass() + const hoverOutlinePass = generateHoverOutlinePass() - postProcessing.outputNode = selectedOutlinePass - .add(hoverOutlinePass) - .add(scenePass); - postProcessingRef.current = postProcessing; + postProcessing.outputNode = selectedOutlinePass.add(hoverOutlinePass).add(scenePass) + postProcessingRef.current = postProcessing return () => { if (postProcessingRef.current) { - postProcessingRef.current.dispose(); + postProcessingRef.current.dispose() } - postProcessingRef.current = null; - }; - }, [renderer, scene, camera]); + postProcessingRef.current = null + } + }, [renderer, scene, camera]) useFrame(() => { if (postProcessingRef.current) { - postProcessingRef.current.render(); + postProcessingRef.current.render() } - }, 1); + }, 1) - return null; -}; + return null +} -export default PostProcessingPasses; +export default PostProcessingPasses diff --git a/packages/viewer/src/hooks/use-grid-events.ts b/packages/viewer/src/hooks/use-grid-events.ts index 069ea22d..ebcb21d9 100644 --- a/packages/viewer/src/hooks/use-grid-events.ts +++ b/packages/viewer/src/hooks/use-grid-events.ts @@ -1,33 +1,33 @@ -import { emitter, EventSuffix, GridEvent } from "@pascal-app/core"; -import { ThreeEvent } from "@react-three/fiber"; +import { type EventSuffix, emitter, type GridEvent } from '@pascal-app/core' +import type { ThreeEvent } from '@react-three/fiber' export function useGridEvents() { const emit = (suffix: EventSuffix, e: ThreeEvent) => { - const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`; + const eventKey = `grid:${suffix}` as `grid:${EventSuffix}` const payload: GridEvent = { position: [e.point.x, e.point.y, e.point.z], - }; + } - emitter.emit(eventKey, payload); - }; + emitter.emit(eventKey, payload) + } return { onPointerDown: (e: ThreeEvent) => { - if (e.button !== 0) return; - emit("pointerdown", e); + if (e.button !== 0) return + emit('pointerdown', e) }, onPointerUp: (e: ThreeEvent) => { - if (e.button !== 0) return; - emit("pointerup", e); + if (e.button !== 0) return + emit('pointerup', e) }, onClick: (e: ThreeEvent) => { - if (e.button !== 0) return; - emit("click", e); + if (e.button !== 0) return + emit('click', e) }, - onPointerEnter: (e: ThreeEvent) => emit("enter", e), - onPointerLeave: (e: ThreeEvent) => emit("leave", e), - onPointerMove: (e: ThreeEvent) => emit("move", e), - onDoubleClick: (e: ThreeEvent) => emit("double-click", e), - onContextMenu: (e: ThreeEvent) => emit("context-menu", e), - }; + onPointerEnter: (e: ThreeEvent) => emit('enter', e), + onPointerLeave: (e: ThreeEvent) => emit('leave', e), + onPointerMove: (e: ThreeEvent) => emit('move', e), + onDoubleClick: (e: ThreeEvent) => emit('double-click', e), + onContextMenu: (e: ThreeEvent) => emit('context-menu', e), + } } diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index a35e3aff..f0dd948e 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -1,60 +1,55 @@ import { - BuildingNode, + type BuildingNode, + type EventSuffix, emitter, - EventSuffix, - ItemEvent, - ItemNode, - WallEvent, - WallNode, -} from "@pascal-app/core"; -import { ThreeEvent } from "@react-three/fiber"; -import { BuildingEvent } from "../../../core/src/events/bus"; + type ItemEvent, + type ItemNode, + type WallEvent, + type WallNode, +} from '@pascal-app/core' +import type { ThreeEvent } from '@react-three/fiber' +import type { BuildingEvent } from '../../../core/src/events/bus' type NodeConfig = { - item: { node: ItemNode; event: ItemEvent }; - wall: { node: WallNode; event: WallEvent }; - building: { node: BuildingNode; event: BuildingEvent }; -}; + item: { node: ItemNode; event: ItemEvent } + wall: { node: WallNode; event: WallEvent } + building: { node: BuildingNode; event: BuildingEvent } +} -type NodeType = keyof NodeConfig; +type NodeType = keyof NodeConfig -export function useNodeEvents( - node: NodeConfig[T]["node"], - type: T, -) { +export function useNodeEvents(node: NodeConfig[T]['node'], type: T) { const emit = (suffix: EventSuffix, e: ThreeEvent) => { - const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`; - const localPoint = e.object.worldToLocal(e.point.clone()); + const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}` + const localPoint = e.object.worldToLocal(e.point.clone()) const payload = { node, position: [e.point.x, e.point.y, e.point.z], localPosition: [localPoint.x, localPoint.y, localPoint.z], - normal: e.face - ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] - : undefined, + normal: e.face ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] : undefined, stopPropagation: () => e.stopPropagation(), - } as NodeConfig[T]["event"]; + } as NodeConfig[T]['event'] - emitter.emit(eventKey, payload); - }; + emitter.emit(eventKey, payload) + } return { onPointerDown: (e: ThreeEvent) => { - if (e.button !== 0) return; - emit("pointerdown", e); + if (e.button !== 0) return + emit('pointerdown', e) }, onPointerUp: (e: ThreeEvent) => { - if (e.button !== 0) return; - emit("pointerup", e); + if (e.button !== 0) return + emit('pointerup', e) }, onClick: (e: ThreeEvent) => { - if (e.button !== 0) return; - emit("click", e); + if (e.button !== 0) return + emit('click', e) }, - onPointerEnter: (e: ThreeEvent) => emit("enter", e), - onPointerLeave: (e: ThreeEvent) => emit("leave", e), - onPointerMove: (e: ThreeEvent) => emit("move", e), - onDoubleClick: (e: ThreeEvent) => emit("double-click", e), - onContextMenu: (e: ThreeEvent) => emit("context-menu", e), - }; + onPointerEnter: (e: ThreeEvent) => emit('enter', e), + onPointerLeave: (e: ThreeEvent) => emit('leave', e), + onPointerMove: (e: ThreeEvent) => emit('move', e), + onDoubleClick: (e: ThreeEvent) => emit('double-click', e), + onContextMenu: (e: ThreeEvent) => emit('context-menu', e), + } } diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index c8d80d17..31d4fd9e 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -1,5 +1,3 @@ -export { default as Viewer } from "./components/viewer"; - -export { default as useViewer } from "./store/use-viewer"; - -export { useGridEvents } from "./hooks/use-grid-events"; +export { default as Viewer } from './components/viewer' +export { useGridEvents } from './hooks/use-grid-events' +export { default as useViewer } from './store/use-viewer' diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 856e1e11..0634b422 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -1,58 +1,58 @@ -"use client"; +'use client' -import { BuildingNode, ItemNode, LevelNode, Zone } from "@pascal-app/core"; -import { Object3D } from "three"; +import type { BuildingNode, ItemNode, LevelNode, Zone } from '@pascal-app/core' +import type { Object3D } from 'three' -import { create } from "zustand"; +import { create } from 'zustand' type SelectionPath = { - buildingId: BuildingNode["id"] | null; - levelId: LevelNode["id"] | null; - zoneId: Zone["id"] | null; - selectedIds: ItemNode["id"][]; // For items/assets (multi-select) -}; + buildingId: BuildingNode['id'] | null + levelId: LevelNode['id'] | null + zoneId: Zone['id'] | null + selectedIds: ItemNode['id'][] // For items/assets (multi-select) +} type Outliner = { - selectedObjects: Object3D[]; - hoveredObjects: Object3D[]; -}; + selectedObjects: Object3D[] + hoveredObjects: Object3D[] +} type ViewerState = { - selection: SelectionPath; - levelMode: "stacked" | "exploded" | "solo" | "manual"; + selection: SelectionPath + levelMode: 'stacked' | 'exploded' | 'solo' | 'manual' // Actions - setLevelMode: (mode: "stacked" | "exploded" | "solo" | "manual") => void; + setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void // Smart selection update - setSelection: (updates: Partial) => void; - resetSelection: () => void; + setSelection: (updates: Partial) => 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()((set, get) => ({ selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, - levelMode: "stacked", + levelMode: 'stacked', setLevelMode: (mode) => set({ levelMode: mode }), setSelection: (updates) => set((state) => { - const newSelection = { ...state.selection, ...updates }; + const newSelection = { ...state.selection, ...updates } // Hierarchy Guard: If we change a high-level parent, reset the children if (updates.buildingId !== undefined) { - newSelection.levelId = null; - newSelection.zoneId = null; - newSelection.selectedIds = []; + newSelection.levelId = null + newSelection.zoneId = null + newSelection.selectedIds = [] } else if (updates.levelId !== undefined) { - newSelection.zoneId = null; - newSelection.selectedIds = []; + newSelection.zoneId = null + newSelection.selectedIds = [] } else if (updates.zoneId !== undefined) { - newSelection.selectedIds = []; + newSelection.selectedIds = [] } - return { selection: newSelection }; + return { selection: newSelection } }), resetSelection: () => @@ -66,6 +66,6 @@ const useViewer = create()((set, get) => ({ }), outliner: { selectedObjects: [], hoveredObjects: [] }, -})); +})) -export default useViewer; +export default useViewer diff --git a/packages/viewer/src/systems/level/level-system.tsx b/packages/viewer/src/systems/level/level-system.tsx index cf3941ab..3f573f6d 100644 --- a/packages/viewer/src/systems/level/level-system.tsx +++ b/packages/viewer/src/systems/level/level-system.tsx @@ -1,24 +1,24 @@ -import { LevelNode, sceneRegistry, useScene } from "@pascal-app/core"; -import { useFrame } from "@react-three/fiber"; -import { lerp } from "three/src/math/MathUtils.js"; -import useViewer from "../../store/use-viewer"; +import { type LevelNode, sceneRegistry, useScene } from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import { lerp } from 'three/src/math/MathUtils.js' +import useViewer from '../../store/use-viewer' -const LEVEL_HEIGHT = 2.5; -const EXPLODED_GAP = 5; +const LEVEL_HEIGHT = 2.5 +const EXPLODED_GAP = 5 export const LevelSystem = () => { useFrame((_, delta) => { - const levelMode = useViewer.getState().levelMode; + const levelMode = useViewer.getState().levelMode sceneRegistry.byType.level.forEach((levelId) => { - const obj = sceneRegistry.nodes.get(levelId); + const obj = sceneRegistry.nodes.get(levelId) if (obj) { - const level = useScene.getState().nodes[levelId as LevelNode["id"]]; + const level = useScene.getState().nodes[levelId as LevelNode['id']] const targetY = ((level as any).level || 0) * - (LEVEL_HEIGHT + (levelMode === "stacked" ? 0 : EXPLODED_GAP)); - obj.position.y = lerp(obj.position.y, targetY, delta * 3); + (LEVEL_HEIGHT + (levelMode === 'stacked' ? 0 : EXPLODED_GAP)) + obj.position.y = lerp(obj.position.y, targetY, delta * 3) } - }); - }); - return null; -}; + }) + }) + return null +}