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