registry / systems / renderer

This commit is contained in:
wass08
2026-01-15 10:43:32 +09:00
parent 7a4d6e397d
commit 0c07c482a5
93 changed files with 440 additions and 75 deletions
+33 -1
View File
@@ -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>
);
};
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+5 -7
View File
@@ -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=="],
+5 -3
View File
@@ -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]);
}
+9 -2
View File
@@ -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";
+3 -1
View File
@@ -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]),
+2 -5
View File
@@ -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(),
+2
View File
@@ -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>;
+15
View File
@@ -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>
); );
}; };