registry / systems / renderer
This commit is contained in:
@@ -14,14 +14,16 @@
|
||||
"peerDependencies": {
|
||||
"@react-three/drei": "^10",
|
||||
"@react-three/fiber": "^9",
|
||||
"three": "^0.176"
|
||||
"three": "^0.182"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-three/drei": "^10",
|
||||
"@react-three/fiber": "^9",
|
||||
"@repo/typescript-config": "*",
|
||||
"three": "^0.176",
|
||||
"typescript": "5.9.2"
|
||||
"@types/react": "^19.2.2",
|
||||
"three": "^0.182",
|
||||
"typescript": "5.9.2",
|
||||
"@types/three": "^0.182.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"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
|
||||
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
|
||||
export * from "./schema"
|
||||
export * from "./schema";
|
||||
|
||||
@@ -13,7 +13,9 @@ export const ItemNode = BaseNode.extend({
|
||||
.object({
|
||||
category: 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(),
|
||||
// These are "Corrective" transforms to normalize the GLB
|
||||
offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import dedent from "dedent";
|
||||
import { z } from "zod";
|
||||
import { BaseNode, nodeType, objectId } from "../base";
|
||||
import { ItemNode } from "./item";
|
||||
// import { DoorNode } from "./door";
|
||||
// import { ItemNode } from "./item";
|
||||
// import { WindowNode } from "./window";
|
||||
@@ -8,11 +9,7 @@ import { BaseNode, nodeType, objectId } from "../base";
|
||||
export const WallNode = BaseNode.extend({
|
||||
id: objectId("wall"),
|
||||
type: nodeType("wall"),
|
||||
// get children() {
|
||||
// return z
|
||||
// .array(z.discriminatedUnion("type", [DoorNode, WindowNode, ItemNode]))
|
||||
// .default([]);
|
||||
// },
|
||||
children: z.array(ItemNode.shape.id).default([]),
|
||||
// Specific props
|
||||
thickness: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import z from "zod";
|
||||
import { BuildingNode } from "./nodes/building";
|
||||
import { ItemNode } from "./nodes/item";
|
||||
import { LevelNode } from "./nodes/level";
|
||||
import { SiteNode } from "./nodes/site";
|
||||
import { WallNode } from "./nodes/wall";
|
||||
@@ -9,6 +10,7 @@ export const AnyNode = z.discriminatedUnion("type", [
|
||||
BuildingNode,
|
||||
LevelNode,
|
||||
WallNode,
|
||||
ItemNode,
|
||||
]);
|
||||
|
||||
export type AnyNode = z.infer<typeof AnyNode>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { ItemNode } from "../schema";
|
||||
import { LevelNode } from "../schema/nodes/level";
|
||||
import { WallNode } from "../schema/nodes/wall";
|
||||
import { AnyNode, AnyNodeId } from "../schema/types";
|
||||
@@ -72,6 +73,19 @@ const useScene = create<SceneState>()((set, get) => ({
|
||||
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);
|
||||
|
||||
// Define all nodes flat
|
||||
@@ -83,6 +97,7 @@ const useScene = create<SceneState>()((set, get) => ({
|
||||
[wall1.id]: wall1,
|
||||
[wall2.id]: wall2,
|
||||
[wall3.id]: wall3,
|
||||
[window1.id]: window1,
|
||||
};
|
||||
|
||||
// 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";
|
||||
|
||||
import { AnyNode, LevelNode, useScene, WallNode } from "@pascal-app/core";
|
||||
import { useRef } from "react";
|
||||
import * as THREE from "three/webgpu";
|
||||
import { useScene } from "@pascal-app/core";
|
||||
import { NodeRenderer } from "./node-renderer";
|
||||
|
||||
export const SceneRenderer = () => {
|
||||
const rootNodes = useScene((state) => state.rootNodeIds);
|
||||
@@ -11,56 +10,3 @@ export const SceneRenderer = () => {
|
||||
<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 { Canvas, ThreeToJSXElements } from "@react-three/fiber";
|
||||
|
||||
import { LevelSystem, WallSystem } from "@pascal-app/core";
|
||||
import { extend } from "@react-three/fiber";
|
||||
import * as THREE from "three/webgpu";
|
||||
import { SceneRenderer } from "../renderers/scene-renderer";
|
||||
@@ -25,10 +26,15 @@ const Viewer: React.FC<ViewerProps> = () => {
|
||||
return renderer;
|
||||
}}
|
||||
shadows
|
||||
camera={{ position: [3, 3, 3], fov: 50 }}
|
||||
>
|
||||
<OrbitControls />
|
||||
<Environment preset="sunset" />
|
||||
<SceneRenderer />
|
||||
|
||||
{/* Default Systems */}
|
||||
<LevelSystem />
|
||||
<WallSystem />
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user