206 lines
6.2 KiB
TypeScript
206 lines
6.2 KiB
TypeScript
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])
|
|
.filter((n): n is AnyNode => n !== undefined);
|
|
|
|
// 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;
|
|
}
|