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