From c33a39944ab66d91474a267d8e912b5eb5b14cda Mon Sep 17 00:00:00 2001 From: Thomas Fuhrmann Date: Wed, 5 Mar 2025 14:37:44 +0100 Subject: [PATCH] Work on closed sliced surfaces --- app/three/utils/build-clipping-plane.ts | 252 +++++++++++++++++++++++- app/three/utils/build-meshes.ts | 15 +- app/three/utils/init.ts | 21 +- package-lock.json | 13 ++ package.json | 4 +- 5 files changed, 285 insertions(+), 20 deletions(-) diff --git a/app/three/utils/build-clipping-plane.ts b/app/three/utils/build-clipping-plane.ts index fc50c60..cfec042 100644 --- a/app/three/utils/build-clipping-plane.ts +++ b/app/three/utils/build-clipping-plane.ts @@ -1,16 +1,27 @@ import { + BufferAttribute, + BufferGeometry, DoubleSide, + Group, Mesh, MeshBasicMaterial, + MeshStandardMaterial, Object3DEventMap, PerspectiveCamera, Plane, PlaneGeometry, + Scene, + Vector2, Vector3, WebGLRenderer, } from "three"; -import { DragControls, OrbitControls } from "three/examples/jsm/Addons.js"; +import { + ConvexGeometry, + DragControls, + OrbitControls, +} from "three/examples/jsm/Addons.js"; import { Extent } from "./build-scene"; +import earcut from "earcut"; enum Orientation { X = "x", @@ -27,7 +38,9 @@ export function createClippingPlanes( renderer: WebGLRenderer, camera: PerspectiveCamera, orbitControls: OrbitControls, - extent: Extent + extent: Extent, + meshes: Mesh[], + scene: Scene ) { const planesData = [ { @@ -139,6 +152,13 @@ export function createClippingPlanes( dragControls.addEventListener("dragstart", () => { // Disable OrbitControls when dragging starts orbitControls.enabled = false; + + // Remove existing cap meshes + let capMeshGroup = scene.getObjectByName("cap-mesh-group"); + while (capMeshGroup) { + scene.remove(capMeshGroup); + capMeshGroup = scene.getObjectByName("cap-mesh-group"); + } }); dragControls.addEventListener("dragend", () => { @@ -221,6 +241,24 @@ export function createClippingPlanes( // Resize other meshes resizeMeshes(Orientation.X, newX, planeMeshMap as PlaneMeshMap, extent); } + + // Remove existing cap meshes + let capMeshGroup = scene.getObjectByName("cap-mesh-group"); + while (capMeshGroup) { + scene.remove(capMeshGroup); + capMeshGroup = scene.getObjectByName("cap-mesh-group"); + } + + const capMeshes = generateCapMeshes(meshes, plane); + + if (capMeshes.length > 0) { + // Add new cap meshes + const newCapMeshGroup = new Group(); + + newCapMeshGroup.add(...capMeshes); + newCapMeshGroup.name = "cap-mesh-group"; + scene.add(newCapMeshGroup); + } }); return { planeMeshes, planes }; @@ -312,3 +350,213 @@ function resizeMeshes( ); } } + +// Extract contour and generate cap +function generateCapMeshes(meshes: Mesh[], plane: Plane) { + const capMeshes: Mesh[] = []; + for (let mesh of meshes) { + const position = mesh.geometry.attributes.position.array; + const indices = mesh.geometry.index ? mesh.geometry.index.array : null; + const edges: Array<[Vector3, Vector3]> = []; + + for ( + let i = 0; + i < (indices ? indices.length : position.length / 3); + i += 3 + ) { + const i1 = indices ? indices[i] * 3 : i * 3; + const i2 = indices ? indices[i + 1] * 3 : (i + 1) * 3; + const i3 = indices ? indices[i + 2] * 3 : (i + 2) * 3; + + const v1 = new Vector3(position[i1], position[i1 + 1], position[i1 + 2]); + const v2 = new Vector3(position[i2], position[i2 + 1], position[i2 + 2]); + const v3 = new Vector3(position[i3], position[i3 + 1], position[i3 + 2]); + + // Check if the triangle is cut by the plane + const d1 = plane.distanceToPoint(v1); + const d2 = plane.distanceToPoint(v2); + const d3 = plane.distanceToPoint(v3); + + // Compute intersection points + const intersections = []; + + if (d1 * d2 < 0) intersections.push(intersectEdge(v1, v2, d1, d2)); + if (d2 * d3 < 0) intersections.push(intersectEdge(v2, v3, d2, d3)); + if (d3 * d1 < 0) intersections.push(intersectEdge(v3, v1, d3, d1)); + + if (intersections.length === 2) { + edges.push([intersections[0], intersections[1]]); + } + } + + const polygons: Vector3[][] = buildPolygons(edges); + + const material = new MeshStandardMaterial({ + color: (mesh.material as MeshStandardMaterial).color, + side: DoubleSide, + polygonOffset: true, + polygonOffsetFactor: -1, + polygonOffsetUnits: -1, + }); + + const localMeshes = polygons.map((polygon) => { + const geometry = triangulatePolygon(polygon, plane); + + const capMesh = new Mesh(geometry, material); + + // Offset mesh to avoid flickering + const offset = 10; + const normal = plane.normal.clone().multiplyScalar(offset); + + const positionAttr = capMesh.geometry.attributes.position; + for (let i = 0; i < positionAttr.count; i++) { + const x = positionAttr.getX(i) - normal.x; + const y = positionAttr.getY(i) - normal.y; + const z = positionAttr.getZ(i) - normal.z; + positionAttr.setXYZ(i, x, y, z); + } + positionAttr.needsUpdate = true; + + return capMesh; + }); + + capMeshes.push(...localMeshes); + } + + return capMeshes; +} + +// Build polygons by grouping connected intersection edges +function buildPolygons(edges: Array<[Vector3, Vector3]>): Vector3[][] { + const polygons: Vector3[][] = []; + const edgeMap = new Map(); + + // Populate the edgeMap for fast lookups + for (const [v1, v2] of edges) { + edgeMap.set(`${v1.x},${v1.y},${v1.z}-${v2.x},${v2.y},${v2.z}`, [v1, v2]); + } + + while (edgeMap.size > 0) { + const polygon: Vector3[] = []; + const [start, end] = edgeMap.values().next().value; // Take any edge as a start + edgeMap.delete( + `${start.x},${start.y},${start.z}-${end.x},${end.y},${end.z}` + ); + + polygon.push(start, end); + let lastPoint = end; + while (true) { + let foundNextEdge = false; + + for (const [key, [v1, v2]] of edgeMap) { + // Check if v1 or v2 is the last point to continue the polygon + if (lastPoint.distanceTo(v1) < 1e-6) { + polygon.push(v2); + lastPoint = v2; + edgeMap.delete(key); + foundNextEdge = true; + break; + } else if (lastPoint.distanceTo(v2) < 1e-6) { + polygon.push(v1); + lastPoint = v1; + edgeMap.delete(key); + foundNextEdge = true; + break; + } + } + + if (!foundNextEdge) break; // Stop if no connected edge is found + } + + if (polygon.length >= 3) polygons.push(polygon); // Ensure valid polygon with at least 3 vertices + } + + return polygons; +} + +// Function to triangulate the sliced polygon vertices +function triangulatePolygon(vertices: Vector3[], plane: Plane) { + // Project vertices to the plane + const projectedVertices = projectVerticesToPlane(vertices, plane); + + // Sort vertices in counter-clockwise order + const sortedVertices = sortVertices(projectedVertices); + + // Convert the sorted 2D vertices back to flat array + const flatVertices: number[] = []; + sortedVertices.forEach((v) => { + flatVertices.push(v.x, v.y); + }); + + // Use earcut to triangulate the 2D polygon (returns an array of indices) + const indices = earcut(flatVertices); + + // Create geometry for the triangulated result + const geometry = new BufferGeometry(); + const positions: number[] = []; + + vertices.forEach((v) => { + positions.push(v.x, v.y, v.z); + }); + + geometry.setAttribute( + "position", + new BufferAttribute(new Float32Array(positions), 3) + ); + geometry.setIndex(indices); + + return geometry; +} + +function projectVerticesToPlane(vertices: Vector3[], plane: Plane) { + // Choose a reference point on the plane (e.g., centroid) + const planeOrigin = vertices + .reduce((sum, v) => sum.add(v), new Vector3()) + .divideScalar(vertices.length); + + // Define local 2D coordinate system on the plane + const N = plane.normal.clone().normalize(); + let T = new Vector3(1, 0, 0); + + // Ensure T is not parallel to N + if (Math.abs(N.dot(T)) > 0.9) { + T.set(0, 1, 0); + } + + const U = new Vector3().crossVectors(N, T).normalize(); // First tangent + const V = new Vector3().crossVectors(N, U).normalize(); // Second tangent + + // Project each vertex to 2D space in the plane + return vertices.map((v) => { + const relativePos = v.clone().sub(planeOrigin); + return new Vector2(relativePos.dot(U), relativePos.dot(V)); + }); +} + +function sortVertices(vertices: Vector2[]) { + const centroid = new Vector2(0, 0); + + // Compute the centroid of the vertices + vertices.forEach((v) => centroid.add(v)); + centroid.divideScalar(vertices.length); + + // Sort vertices by the angle with the centroid + vertices.sort((a, b) => { + return ( + Math.atan2(a.y - centroid.y, a.x - centroid.x) - + Math.atan2(b.y - centroid.y, b.x - centroid.x) + ); + }); + + return vertices; +} + +// Function to find the intersection point between an edge and a plane +function intersectEdge(v1: Vector3, v2: Vector3, d1: number, d2: number) { + const t = d1 / (d1 - d2); + return new Vector3( + v1.x + t * (v2.x - v1.x), + v1.y + t * (v2.y - v1.y), + v1.z + t * (v2.z - v1.z) + ); +} diff --git a/app/three/utils/build-meshes.ts b/app/three/utils/build-meshes.ts index c7f356e..a44a1ee 100644 --- a/app/three/utils/build-meshes.ts +++ b/app/three/utils/build-meshes.ts @@ -2,14 +2,9 @@ import { BufferAttribute, BufferGeometry, DoubleSide, - FrontSide, - Group, Mesh, MeshStandardMaterial, Plane, - PlaneHelper, - Scene, - Vector3, } from "three"; import { uniforms } from "./uniforms"; @@ -26,7 +21,7 @@ interface MappedFeature { preview: { legend_color: string; legend_text: string }; } -async function buildMesh(layerData: MappedFeature, clippingPlanes: Plane[]) { +async function buildMesh(layerData: MappedFeature) { const color = `#${layerData.preview.legend_color}`; const name = layerData.preview.legend_text; const geomId = layerData.featuregeom_id.toString(); @@ -53,7 +48,6 @@ async function buildMesh(layerData: MappedFeature, clippingPlanes: Plane[]) { flatShading: true, side: DoubleSide, wireframe: false, - clippingPlanes: clippingPlanes, clipIntersection: false, }); @@ -75,15 +69,12 @@ async function buildMesh(layerData: MappedFeature, clippingPlanes: Plane[]) { return mesh; } -export async function buildMeshes( - mappedFeatures: MappedFeature[], - clippingPlanes: Plane[] -) { +export async function buildMeshes(mappedFeatures: MappedFeature[]) { const meshes = []; for (let i = 0; i < mappedFeatures.length; i++) { const layerData = mappedFeatures[i]; if (layerData.name !== "Topography") { - const mesh = await buildMesh(layerData, clippingPlanes); + const mesh = await buildMesh(layerData); meshes.push(mesh); } } diff --git a/app/three/utils/init.ts b/app/three/utils/init.ts index facaaa7..97265bb 100644 --- a/app/three/utils/init.ts +++ b/app/three/utils/init.ts @@ -25,19 +25,30 @@ export async function init(container: HTMLElement) { extent ); + // Create the 3D model + const meshes = await buildMeshes(mappedFeatures); + const model = new Group(); + model.add(...meshes); + model.name = "3d-model"; + scene.add(model); + + // Create the clipping planes and add them to the scene const { planeMeshes, planes } = createClippingPlanes( renderer, camera, controls, - extent + extent, + meshes, + scene ); scene.add(...planeMeshes); - const meshes = await buildMeshes(mappedFeatures, planes); - const mappedFeaturesGroup = new Group(); - mappedFeaturesGroup.add(...meshes); - scene.add(mappedFeaturesGroup); + // Add clipping planes to the meshes + for (let mesh of meshes) { + mesh.material.clippingPlanes = planes; + } + // Add a coordinate grid to the scene const { gridHelper, annotations } = buildGrid(extent); const annotationsGroup = new Group(); annotationsGroup.add(...annotations); diff --git a/package-lock.json b/package-lock.json index fa431f8..4b9159a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "3d-viewer", "version": "0.1.0", "dependencies": { + "earcut": "^3.0.1", "next": "15.1.7", "proj4": "^2.15.0", "react": "^19.0.0", @@ -16,6 +17,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@types/earcut": "^3.0.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -854,6 +856,12 @@ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", "dev": true }, + "node_modules/@types/earcut": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", + "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==", + "dev": true + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -1897,6 +1905,11 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.1.tgz", + "integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==" + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", diff --git a/package.json b/package.json index 946384e..d92bc1c 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "earcut": "^3.0.1", "next": "15.1.7", "proj4": "^2.15.0", "react": "^19.0.0", @@ -17,6 +18,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@types/earcut": "^3.0.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -27,4 +29,4 @@ "tailwindcss": "^3.4.1", "typescript": "^5" } -} \ No newline at end of file +}