registry / systems / renderer
@@ -1,7 +1,39 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useScene } from "@pascal-app/core";
|
||||||
import { Viewer } from "@pascal-app/viewer";
|
import { Viewer } from "@pascal-app/viewer";
|
||||||
|
|
||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
return <Viewer />;
|
return (
|
||||||
|
<div className="w-full h-full bg-pink-50">
|
||||||
|
<LevelModeSwitcher />
|
||||||
|
<Viewer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LevelModeSwitcher = () => {
|
||||||
|
const setLevelMode = useScene((state) => state.setLevelMode);
|
||||||
|
const levelMode = useScene((state) => state.levelMode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-4 left-4 z-10 flex gap-2">
|
||||||
|
<button
|
||||||
|
className={`px-4 py-2 rounded ${
|
||||||
|
levelMode === "exploded" ? "bg-blue-500 text-white" : "bg-white"
|
||||||
|
}`}
|
||||||
|
onClick={() => setLevelMode("exploded")}
|
||||||
|
>
|
||||||
|
Exploded
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`px-4 py-2 rounded ${
|
||||||
|
levelMode === "stacked" ? "bg-blue-500 text-white" : "bg-white"
|
||||||
|
}`}
|
||||||
|
onClick={() => setLevelMode("stacked")}
|
||||||
|
>
|
||||||
|
Stacked
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -50,13 +50,15 @@
|
|||||||
"@react-three/drei": "^10",
|
"@react-three/drei": "^10",
|
||||||
"@react-three/fiber": "^9",
|
"@react-three/fiber": "^9",
|
||||||
"@repo/typescript-config": "*",
|
"@repo/typescript-config": "*",
|
||||||
"three": "^0.176",
|
"@types/react": "^19.2.2",
|
||||||
|
"@types/three": "^0.182.0",
|
||||||
|
"three": "^0.182",
|
||||||
"typescript": "5.9.2",
|
"typescript": "5.9.2",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@react-three/drei": "^10",
|
"@react-three/drei": "^10",
|
||||||
"@react-three/fiber": "^9",
|
"@react-three/fiber": "^9",
|
||||||
"three": "^0.176",
|
"three": "^0.182",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/eslint-config": {
|
"packages/eslint-config": {
|
||||||
@@ -902,7 +904,7 @@
|
|||||||
|
|
||||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||||
|
|
||||||
"three": ["three@0.176.0", "", {}, "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA=="],
|
"three": ["three@0.182.0", "", {}, "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ=="],
|
||||||
|
|
||||||
"three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="],
|
"three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="],
|
||||||
|
|
||||||
@@ -998,8 +1000,6 @@
|
|||||||
|
|
||||||
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||||
|
|
||||||
"@pascal-app/viewer/three": ["three@0.182.0", "", {}, "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||||
@@ -1036,8 +1036,6 @@
|
|||||||
|
|
||||||
"tunnel-rat/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
"tunnel-rat/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
||||||
|
|
||||||
"web/three": ["three@0.182.0", "", {}, "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ=="],
|
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||||
|
|
||||||
"next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|||||||
@@ -14,14 +14,16 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@react-three/drei": "^10",
|
"@react-three/drei": "^10",
|
||||||
"@react-three/fiber": "^9",
|
"@react-three/fiber": "^9",
|
||||||
"three": "^0.176"
|
"three": "^0.182"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-three/drei": "^10",
|
"@react-three/drei": "^10",
|
||||||
"@react-three/fiber": "^9",
|
"@react-three/fiber": "^9",
|
||||||
"@repo/typescript-config": "*",
|
"@repo/typescript-config": "*",
|
||||||
"three": "^0.176",
|
"@types/react": "^19.2.2",
|
||||||
"typescript": "5.9.2"
|
"three": "^0.182",
|
||||||
|
"typescript": "5.9.2",
|
||||||
|
"@types/three": "^0.182.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dedent": "^1.7.1",
|
"dedent": "^1.7.1",
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
import { useLayoutEffect } from "react";
|
||||||
|
|
||||||
|
export const sceneRegistry = {
|
||||||
|
// Master lookup: ID -> Object3D
|
||||||
|
nodes: new Map<string, THREE.Object3D>(),
|
||||||
|
|
||||||
|
// Categorized lookups: Type -> Set of IDs
|
||||||
|
// Using a Set is faster for adding/deleting than an Array
|
||||||
|
byType: {
|
||||||
|
level: new Set<string>(),
|
||||||
|
wall: new Set<string>(),
|
||||||
|
item: new Set<string>(),
|
||||||
|
slab: new Set<string>(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useRegistry(
|
||||||
|
id: string,
|
||||||
|
type: keyof typeof sceneRegistry.byType,
|
||||||
|
ref: React.RefObject<THREE.Object3D>
|
||||||
|
) {
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const obj = ref.current;
|
||||||
|
if (!obj) return;
|
||||||
|
|
||||||
|
// 1. Add to master map
|
||||||
|
sceneRegistry.nodes.set(id, obj);
|
||||||
|
|
||||||
|
// 2. Add to type-specific set
|
||||||
|
sceneRegistry.byType[type].add(id);
|
||||||
|
|
||||||
|
// 4. Cleanup when component unmounts
|
||||||
|
return () => {
|
||||||
|
sceneRegistry.nodes.delete(id);
|
||||||
|
sceneRegistry.byType[type].delete(id);
|
||||||
|
};
|
||||||
|
}, [id, type, ref]);
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
// Store
|
// Store
|
||||||
export { default as useScene } from "./store/useScene"
|
export { default as useScene } from "./store/useScene";
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export { useRegistry } from "./hooks/scene-registry/scene-registry";
|
||||||
|
|
||||||
|
// Systems
|
||||||
|
export { LevelSystem } from "./systems/level/level-system";
|
||||||
|
export { WallSystem } from "./systems/wall/wall-system";
|
||||||
|
|
||||||
// Schema
|
// Schema
|
||||||
export * from "./schema"
|
export * from "./schema";
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ export const ItemNode = BaseNode.extend({
|
|||||||
.object({
|
.object({
|
||||||
category: z.string(),
|
category: z.string(),
|
||||||
src: z.string(),
|
src: z.string(),
|
||||||
dimensions: z.tuple([z.number(), z.number(), z.number()]), // [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]),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { DoorNode } from "./door";
|
// import { DoorNode } from "./door";
|
||||||
// import { ItemNode } from "./item";
|
// import { ItemNode } from "./item";
|
||||||
// import { WindowNode } from "./window";
|
// import { WindowNode } from "./window";
|
||||||
@@ -8,11 +9,7 @@ import { BaseNode, nodeType, objectId } from "../base";
|
|||||||
export const WallNode = BaseNode.extend({
|
export const WallNode = BaseNode.extend({
|
||||||
id: objectId("wall"),
|
id: objectId("wall"),
|
||||||
type: nodeType("wall"),
|
type: nodeType("wall"),
|
||||||
// get children() {
|
children: z.array(ItemNode.shape.id).default([]),
|
||||||
// return z
|
|
||||||
// .array(z.discriminatedUnion("type", [DoorNode, WindowNode, ItemNode]))
|
|
||||||
// .default([]);
|
|
||||||
// },
|
|
||||||
// Specific props
|
// Specific props
|
||||||
thickness: z.number().optional(),
|
thickness: z.number().optional(),
|
||||||
height: z.number().optional(),
|
height: z.number().optional(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { BuildingNode } from "./nodes/building";
|
import { BuildingNode } from "./nodes/building";
|
||||||
|
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";
|
||||||
@@ -9,6 +10,7 @@ export const AnyNode = z.discriminatedUnion("type", [
|
|||||||
BuildingNode,
|
BuildingNode,
|
||||||
LevelNode,
|
LevelNode,
|
||||||
WallNode,
|
WallNode,
|
||||||
|
ItemNode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type AnyNode = z.infer<typeof AnyNode>;
|
export type AnyNode = z.infer<typeof AnyNode>;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { ItemNode } from "../schema";
|
||||||
import { LevelNode } from "../schema/nodes/level";
|
import { LevelNode } from "../schema/nodes/level";
|
||||||
import { WallNode } from "../schema/nodes/wall";
|
import { WallNode } from "../schema/nodes/wall";
|
||||||
import { AnyNode, AnyNodeId } from "../schema/types";
|
import { AnyNode, AnyNodeId } from "../schema/types";
|
||||||
@@ -72,6 +73,19 @@ const useScene = create<SceneState>()((set, get) => ({
|
|||||||
children: [],
|
children: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const window1 = ItemNode.parse({
|
||||||
|
type: "item",
|
||||||
|
name: "Window",
|
||||||
|
position: [2.5, 0.5, 0],
|
||||||
|
asset: {
|
||||||
|
category: "windows",
|
||||||
|
attachTo: "wall",
|
||||||
|
src: "/items/window-round/model.glb",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
wall0.children.push(window1.id);
|
||||||
|
|
||||||
level0.children.push(wall0.id, wall1.id, wall2.id, wall3.id);
|
level0.children.push(wall0.id, wall1.id, wall2.id, wall3.id);
|
||||||
|
|
||||||
// Define all nodes flat
|
// Define all nodes flat
|
||||||
@@ -83,6 +97,7 @@ const useScene = create<SceneState>()((set, get) => ({
|
|||||||
[wall1.id]: wall1,
|
[wall1.id]: wall1,
|
||||||
[wall2.id]: wall2,
|
[wall2.id]: wall2,
|
||||||
[wall3.id]: wall3,
|
[wall3.id]: wall3,
|
||||||
|
[window1.id]: window1,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Root nodes are the levels
|
// Root nodes are the levels
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { lerp } from "three/src/math/MathUtils.js";
|
||||||
|
import { sceneRegistry } from "../../hooks/scene-registry/scene-registry";
|
||||||
|
import { LevelNode } from "../../schema";
|
||||||
|
import useScene from "../../store/useScene";
|
||||||
|
|
||||||
|
const LEVEL_HEIGHT = 2.5;
|
||||||
|
const EXPLODED_GAP = 5;
|
||||||
|
|
||||||
|
export const LevelSystem = () => {
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
const levelMode = useScene.getState().levelMode;
|
||||||
|
sceneRegistry.byType.level.forEach((levelId) => {
|
||||||
|
const obj = sceneRegistry.nodes.get(levelId);
|
||||||
|
if (obj) {
|
||||||
|
const level = useScene.getState().nodes[levelId as LevelNode["id"]];
|
||||||
|
const targetY =
|
||||||
|
((level as any).level || 0) *
|
||||||
|
(LEVEL_HEIGHT + (levelMode === "stacked" ? 0 : EXPLODED_GAP));
|
||||||
|
obj.position.y = lerp(obj.position.y, targetY, delta * 3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { sceneRegistry } from "../../hooks/scene-registry/scene-registry";
|
||||||
|
import { AnyNode, WallNode } from "../../schema";
|
||||||
|
import useScene from "../../store/useScene";
|
||||||
|
|
||||||
|
export const WallSystem = () => {
|
||||||
|
const { nodes, dirtyNodes, clearDirty } = useScene();
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
if (dirtyNodes.size === 0) return;
|
||||||
|
|
||||||
|
dirtyNodes.forEach((id) => {
|
||||||
|
const node = nodes[id];
|
||||||
|
if (!node) return;
|
||||||
|
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh;
|
||||||
|
|
||||||
|
// 1. If a window is dirty, we actually need to redraw its PARENT wall
|
||||||
|
// if ((node.type === 'window' || node.type === 'door') && node.parentId) {
|
||||||
|
// updateWallGeometry(node.parentId);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 2. If the wall itself is dirty
|
||||||
|
if (node.type === "wall" && mesh) {
|
||||||
|
updateWallGeometry(id);
|
||||||
|
}
|
||||||
|
clearDirty(id); // Reset for next frame
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimization: Logic moved to a vanilla function so it can be called
|
||||||
|
// by the Editor or the System without React overhead
|
||||||
|
function updateWallGeometry(wallId: string) {
|
||||||
|
const node = useScene.getState().nodes[wallId as WallNode["id"]];
|
||||||
|
if (!node) return;
|
||||||
|
if (node.type !== "wall") return;
|
||||||
|
|
||||||
|
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh;
|
||||||
|
if (!mesh) return;
|
||||||
|
|
||||||
|
const childrenIds = node.children || [];
|
||||||
|
const childrenNodes = childrenIds.map(
|
||||||
|
(childId) => useScene.getState().nodes[childId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Perform the Extrusion with Holes logic we discussed
|
||||||
|
const newGeo = generateExtrudedWall(node, childrenNodes);
|
||||||
|
|
||||||
|
mesh.geometry.dispose();
|
||||||
|
mesh.geometry = newGeo;
|
||||||
|
|
||||||
|
mesh.position.set(node.start[0], 0, node.start[1]);
|
||||||
|
|
||||||
|
// Rotate mesh to look at 'end' point
|
||||||
|
const angle = Math.atan2(
|
||||||
|
node.end[1] - node.start[1],
|
||||||
|
node.end[0] - node.start[0]
|
||||||
|
);
|
||||||
|
mesh.rotation.y = -angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateExtrudedWall(
|
||||||
|
wallNode: WallNode,
|
||||||
|
childrenNodes: AnyNode[]
|
||||||
|
) {
|
||||||
|
// 1. Calculate Wall Dimensions
|
||||||
|
const start = new THREE.Vector2(wallNode.start[0], wallNode.start[1]);
|
||||||
|
const end = new THREE.Vector2(wallNode.end[0], wallNode.end[1]);
|
||||||
|
const length = start.distanceTo(end);
|
||||||
|
const height = wallNode.height || 2.5;
|
||||||
|
const thickness = wallNode.thickness || 0.2;
|
||||||
|
|
||||||
|
// 2. Create the Main Wall Shape (a rectangle in 2D)
|
||||||
|
// We draw this on the XY plane, where X is "along the wall" and Y is "height"
|
||||||
|
const shape = new THREE.Shape();
|
||||||
|
shape.moveTo(0, 0);
|
||||||
|
shape.lineTo(length, 0);
|
||||||
|
shape.lineTo(length, height);
|
||||||
|
shape.lineTo(0, height);
|
||||||
|
shape.closePath();
|
||||||
|
|
||||||
|
// 3. Process Openings (Holes)
|
||||||
|
// Compute wall's transform info for converting world coords to wall-local coords
|
||||||
|
const wallStart: [number, number] = [wallNode.start[0], wallNode.start[1]];
|
||||||
|
const wallAngle = Math.atan2(
|
||||||
|
wallNode.end[1] - wallNode.start[1],
|
||||||
|
wallNode.end[0] - wallNode.start[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
childrenNodes.forEach((child) => {
|
||||||
|
// Only process items that are intended to be wall cutouts
|
||||||
|
if (child.type !== "item") return;
|
||||||
|
|
||||||
|
const childMesh = sceneRegistry.nodes.get(child.id);
|
||||||
|
if (!childMesh) return;
|
||||||
|
|
||||||
|
const cutoutMesh = childMesh.getObjectByName("cutout") as THREE.Mesh;
|
||||||
|
if (!cutoutMesh) return;
|
||||||
|
|
||||||
|
const holePath = createPathFromCutout(cutoutMesh, wallStart, wallAngle);
|
||||||
|
if (holePath) {
|
||||||
|
shape.holes.push(holePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Extrude the Shape into 3D
|
||||||
|
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||||
|
depth: thickness,
|
||||||
|
bevelEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Pivot Alignment
|
||||||
|
// Center the geometry thickness so the "start/end" line is in the middle of the wall
|
||||||
|
geometry.translate(0, 0, -thickness / 2);
|
||||||
|
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Path from a cutout mesh geometry, transforming vertices
|
||||||
|
* from world space to wall-local space.
|
||||||
|
*
|
||||||
|
* Wall-local space:
|
||||||
|
* - Origin at wall start point
|
||||||
|
* - X axis runs along the wall (toward end point)
|
||||||
|
* - Y axis is height (world Y)
|
||||||
|
*/
|
||||||
|
function createPathFromCutout(
|
||||||
|
cutoutMesh: THREE.Mesh,
|
||||||
|
wallStart: [number, number],
|
||||||
|
wallAngle: number
|
||||||
|
): THREE.Path | null {
|
||||||
|
const geometry = cutoutMesh.geometry;
|
||||||
|
if (!geometry) return null;
|
||||||
|
|
||||||
|
const positions = geometry.attributes.position;
|
||||||
|
if (!positions) return null;
|
||||||
|
|
||||||
|
// Update world matrix to get correct world positions
|
||||||
|
cutoutMesh.updateWorldMatrix(true, false);
|
||||||
|
|
||||||
|
// Collect unique vertices (buffer geometry has duplicates for triangulation)
|
||||||
|
const uniquePoints: THREE.Vector2[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const v3 = new THREE.Vector3();
|
||||||
|
|
||||||
|
// Precompute sin/cos for rotation
|
||||||
|
const cosAngle = Math.cos(-wallAngle);
|
||||||
|
const sinAngle = Math.sin(-wallAngle);
|
||||||
|
|
||||||
|
for (let i = 0; i < positions.count; i++) {
|
||||||
|
v3.fromBufferAttribute(positions, i);
|
||||||
|
|
||||||
|
// Transform to world space
|
||||||
|
v3.applyMatrix4(cutoutMesh.matrixWorld);
|
||||||
|
|
||||||
|
// Transform from world space to wall-local space:
|
||||||
|
// 1. Translate so wall start is at origin (in XZ plane)
|
||||||
|
const worldX = v3.x - wallStart[0];
|
||||||
|
const worldZ = v3.z - wallStart[1];
|
||||||
|
|
||||||
|
// 2. Rotate around Y axis to align wall with local X axis
|
||||||
|
// The wall shape is drawn on XY plane, so we need:
|
||||||
|
// - localX = distance along wall
|
||||||
|
// - localY = height (world Y)
|
||||||
|
const localX = worldX * cosAngle - worldZ * sinAngle;
|
||||||
|
const localY = v3.y; // Height stays the same
|
||||||
|
|
||||||
|
// Create a key for deduplication (with small tolerance)
|
||||||
|
const key = `${localX.toFixed(4)},${localY.toFixed(4)}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
uniquePoints.push(new THREE.Vector2(localX, localY));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uniquePoints.length < 3) return null;
|
||||||
|
|
||||||
|
// Sort points in counter-clockwise order around centroid
|
||||||
|
const centroid = new THREE.Vector2(0, 0);
|
||||||
|
for (const p of uniquePoints) {
|
||||||
|
centroid.add(p);
|
||||||
|
}
|
||||||
|
centroid.divideScalar(uniquePoints.length);
|
||||||
|
|
||||||
|
uniquePoints.sort((a, b) => {
|
||||||
|
const angleA = Math.atan2(a.y - centroid.y, a.x - centroid.x);
|
||||||
|
const angleB = Math.atan2(b.y - centroid.y, b.x - centroid.x);
|
||||||
|
return angleA - angleB;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the path
|
||||||
|
const path = new THREE.Path();
|
||||||
|
path.moveTo(uniquePoints[0]?.x || 0, uniquePoints[0]?.y || 0);
|
||||||
|
for (let i = 1; i < uniquePoints.length; i++) {
|
||||||
|
path.lineTo(uniquePoints[i]?.x || 0, uniquePoints[i]?.y || 0);
|
||||||
|
}
|
||||||
|
path.closePath();
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ItemNode, useRegistry } from "@pascal-app/core";
|
||||||
|
import { Clone } from "@react-three/drei/core/Clone";
|
||||||
|
import { useGLTF } from "@react-three/drei/core/Gltf";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { Group } from "three";
|
||||||
|
|
||||||
|
export const ItemRenderer = ({ node }: { node: ItemNode }) => {
|
||||||
|
const ref = useRef<Group>(null!);
|
||||||
|
const { scene, nodes } = useGLTF(node.asset.src);
|
||||||
|
|
||||||
|
useRegistry(node.id, node.type, ref);
|
||||||
|
|
||||||
|
if (nodes.cutout) {
|
||||||
|
nodes.cutout.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Clone
|
||||||
|
ref={ref}
|
||||||
|
object={scene}
|
||||||
|
position={node.position}
|
||||||
|
rotation={node.rotation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { LevelNode, useRegistry } from "@pascal-app/core";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { Group } from "three";
|
||||||
|
import { NodeRenderer } from "../node-renderer";
|
||||||
|
|
||||||
|
export const LevelRenderer = ({ node }: { node: LevelNode }) => {
|
||||||
|
const ref = useRef<Group>(null!);
|
||||||
|
|
||||||
|
useRegistry(node.id, node.type, ref);
|
||||||
|
return (
|
||||||
|
<group ref={ref}>
|
||||||
|
<mesh receiveShadow>
|
||||||
|
<boxGeometry args={[10, 0.1, 10]} />
|
||||||
|
<meshStandardMaterial color="orange" />
|
||||||
|
</mesh>
|
||||||
|
{node.children.map((childId) => (
|
||||||
|
<NodeRenderer key={childId} nodeId={childId} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AnyNode, useScene } from "@pascal-app/core";
|
||||||
|
import { ItemRenderer } from "./item/item-renderer";
|
||||||
|
import { LevelRenderer } from "./level/level-renderer";
|
||||||
|
import { WallRenderer } from "./wall/wall-renderer";
|
||||||
|
|
||||||
|
export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode["id"] }) => {
|
||||||
|
const node = useScene((state) => state.nodes[nodeId]);
|
||||||
|
|
||||||
|
if (!node) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{node.type === "level" && <LevelRenderer node={node} />}
|
||||||
|
{node.type === "item" && <ItemRenderer node={node} />}
|
||||||
|
{node.type === "wall" && <WallRenderer node={node} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnyNode, LevelNode, useScene, WallNode } from "@pascal-app/core";
|
import { useScene } from "@pascal-app/core";
|
||||||
import { useRef } from "react";
|
import { NodeRenderer } from "./node-renderer";
|
||||||
import * as THREE from "three/webgpu";
|
|
||||||
|
|
||||||
export const SceneRenderer = () => {
|
export const SceneRenderer = () => {
|
||||||
const rootNodes = useScene((state) => state.rootNodeIds);
|
const rootNodes = useScene((state) => state.rootNodeIds);
|
||||||
@@ -11,56 +10,3 @@ export const SceneRenderer = () => {
|
|||||||
<NodeRenderer key={nodeId} nodeId={nodeId} />
|
<NodeRenderer key={nodeId} nodeId={nodeId} />
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
const NodeRenderer = ({ nodeId }: { nodeId: AnyNode["id"] }) => {
|
|
||||||
const node = useScene((state) => state.nodes[nodeId]);
|
|
||||||
|
|
||||||
if (!node) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{node.type === "level" && <LevelRenderer node={node} />}
|
|
||||||
{/* {node.type === "item" && <ItemRenderer node={node} />} */}
|
|
||||||
{node.type === "wall" && <WallRenderer node={node} />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LevelRenderer = ({ node }: { node: LevelNode }) => {
|
|
||||||
const ref = useRef<THREE.Group>(null!);
|
|
||||||
|
|
||||||
// useRegistry(node.id, node.type, ref);
|
|
||||||
return (
|
|
||||||
<group ref={ref}>
|
|
||||||
<mesh receiveShadow>
|
|
||||||
<boxGeometry args={[10, 0.1, 10]} />
|
|
||||||
<meshStandardMaterial color="orange" />
|
|
||||||
</mesh>
|
|
||||||
{node.children.map((childId) => (
|
|
||||||
<NodeRenderer key={childId} nodeId={childId} />
|
|
||||||
))}
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const WallRenderer = ({ node }: { node: WallNode }) => {
|
|
||||||
const ref = useRef<THREE.Mesh>(null!);
|
|
||||||
console.log("node", node);
|
|
||||||
|
|
||||||
// useRegistry(node.id, "wall", ref);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<mesh ref={ref} castShadow receiveShadow>
|
|
||||||
{/* WallSystem will replace this geometry in the next frame */}
|
|
||||||
<boxGeometry args={[1, 2, 0.1]} />
|
|
||||||
<meshStandardMaterial color="lightgray" />
|
|
||||||
|
|
||||||
{/* If you want windows to be inside the wall's local coordinate system:
|
|
||||||
render children here.
|
|
||||||
*/}
|
|
||||||
{/* {node.children.map((childId) => (
|
|
||||||
<NodeRenderer key={childId} nodeId={childId} />
|
|
||||||
))} */}
|
|
||||||
</mesh>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useRegistry, WallNode } from "@pascal-app/core";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { Mesh } from "three";
|
||||||
|
import { NodeRenderer } from "../node-renderer";
|
||||||
|
|
||||||
|
export const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||||
|
const ref = useRef<Mesh>(null!);
|
||||||
|
|
||||||
|
useRegistry(node.id, "wall", ref);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh ref={ref} castShadow receiveShadow>
|
||||||
|
{/* WallSystem will replace this geometry in the next frame */}
|
||||||
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
|
<meshStandardMaterial color="lightgray" />
|
||||||
|
|
||||||
|
{node.children.map((childId) => (
|
||||||
|
<NodeRenderer key={childId} nodeId={childId} />
|
||||||
|
))}
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Environment, OrbitControls } from "@react-three/drei";
|
import { Environment, OrbitControls } from "@react-three/drei";
|
||||||
import { Canvas, ThreeToJSXElements } from "@react-three/fiber";
|
import { Canvas, ThreeToJSXElements } from "@react-three/fiber";
|
||||||
|
|
||||||
|
import { LevelSystem, WallSystem } from "@pascal-app/core";
|
||||||
import { extend } from "@react-three/fiber";
|
import { extend } from "@react-three/fiber";
|
||||||
import * as THREE from "three/webgpu";
|
import * as THREE from "three/webgpu";
|
||||||
import { SceneRenderer } from "../renderers/scene-renderer";
|
import { SceneRenderer } from "../renderers/scene-renderer";
|
||||||
@@ -25,10 +26,15 @@ const Viewer: React.FC<ViewerProps> = () => {
|
|||||||
return renderer;
|
return renderer;
|
||||||
}}
|
}}
|
||||||
shadows
|
shadows
|
||||||
|
camera={{ position: [3, 3, 3], fov: 50 }}
|
||||||
>
|
>
|
||||||
<OrbitControls />
|
<OrbitControls />
|
||||||
<Environment preset="sunset" />
|
<Environment preset="sunset" />
|
||||||
<SceneRenderer />
|
<SceneRenderer />
|
||||||
|
|
||||||
|
{/* Default Systems */}
|
||||||
|
<LevelSystem />
|
||||||
|
<WallSystem />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||