Start working on UI

This commit is contained in:
Fuhrmann 2025-03-06 13:36:47 +01:00
parent 07208177fd
commit 8227b4141a
16 changed files with 701 additions and 581 deletions

84
app/components/Form.tsx Normal file
View file

@ -0,0 +1,84 @@
"use client";
import {
Accordion,
Button,
Checkbox,
Label,
TextInput,
ToggleSwitch,
} from "flowbite-react";
import { useContext, useState } from "react";
import {
MapSceneContext,
MapSceneContextType,
} from "../providers/map-scene-provider";
import { Mesh, MeshStandardMaterial } from "three";
export function Form() {
const [enabled, setEnabled] = useState<boolean>(true);
const { mapScene } = useContext(MapSceneContext) as MapSceneContextType;
function handleChange() {
if (!mapScene) return;
mapScene.toggleClippingBox();
setEnabled(!enabled);
}
function handleCheckboxChange(name: string) {
if (!mapScene) return;
const mesh = mapScene.model.getObjectByName(name);
if (mesh) {
mesh.visible = !mesh.visible;
}
}
return (
<div className="w-full flex flex-col gap-2 overflow-y-auto">
<div className="w-full flex flex-col gap-3 p-4 border border-gray-200 rounded shadow">
<ToggleSwitch
checked={enabled}
label="Toggle Slicing Box"
onChange={handleChange}
/>
<Accordion>
<Accordion.Panel>
<Accordion.Title>Layers</Accordion.Title>
<Accordion.Content>
<div className="mt-2">
{mapScene?.model.children.map((child) => {
const key = `toggle-visibility-${child.name}`;
const color = `#${(
(child as Mesh).material as MeshStandardMaterial
).color.getHexString()}`;
return (
<div key={key} className="flex items-center ml-2">
<span
className="inline-block w-4 h-4"
style={{
backgroundColor: color,
}}
></span>
<Checkbox
id={key}
defaultChecked
onChange={() => handleCheckboxChange(child.name)}
className="ml-2"
/>
<Label htmlFor={key} className="ml-2">
{child.name}
</Label>
</div>
);
})}
</div>
</Accordion.Content>
</Accordion.Panel>
</Accordion>
</div>
</div>
);
}

View file

@ -1,17 +1,31 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useContext, useEffect, useRef } from "react";
import { init } from "../three/utils/init"; import { MapScene } from "../three/MapScene";
import {
MapSceneContext,
MapSceneContextType,
} from "../providers/map-scene-provider";
export function Map() { export function Map() {
const divRef = useRef<HTMLDivElement>(null); const divRef = useRef<HTMLDivElement>(null);
const { setMapScene } = useContext(MapSceneContext) as MapSceneContextType;
useEffect(() => { useEffect(() => {
let ignore = false; let ignore = false;
if (!divRef.current) return; if (!divRef.current) return;
async function loadScene() {
if (divRef.current) {
const _mapScene = await MapScene.create(divRef.current, "20");
if (_mapScene) {
setMapScene(_mapScene);
}
}
}
if (!ignore) { if (!ignore) {
init(divRef.current); loadScene();
} }
return () => { return () => {

View file

@ -1,9 +1,21 @@
import { Map } from "./components/Map"; import { Map } from "./components/Map";
import { Form } from "./components/Form";
import { MapSceneProvider } from "./providers/map-scene-provider";
export default function Home() { export default function Home() {
return ( return (
<div className="w-screen h-screen"> <div className="w-screen h-screen">
<main className="h-screen"> <main className="h-screen">
<Map></Map> <MapSceneProvider>
<div className="flex h-full">
<div className="flex-1">
<Map></Map>
</div>
<div className="w-[480px] p-4 flex flex-col items-center">
<Form></Form>
</div>
</div>
</MapSceneProvider>
</main> </main>
</div> </div>
); );

View file

@ -0,0 +1,36 @@
"use client";
import {
createContext,
Dispatch,
ReactNode,
SetStateAction,
useState,
} from "react";
import { MapScene } from "../three/MapScene";
// Declare MapScene context
export type MapSceneContextType = {
mapScene: MapScene | null;
setMapScene: Dispatch<SetStateAction<MapScene | null>>;
};
// Context for MapScene
export const MapSceneContext = createContext<MapSceneContextType | null>(null);
// Context provider for MapScene
export const MapSceneProvider = ({ children }: { children: ReactNode }) => {
const [mapScene, setMapScene] = useState<MapScene | null>(null);
return (
<MapSceneContext.Provider
value={{
mapScene: mapScene,
setMapScene: setMapScene,
}}
>
{children}
</MapSceneContext.Provider>
);
};

105
app/three/MapScene.ts Normal file
View file

@ -0,0 +1,105 @@
import { AxesHelper, Group, Scene } from "three";
import { buildMeshes } from "./utils/build-meshes";
import { Extent, buildScene } from "./utils/build-scene";
import { getMetadata } from "./utils/utils";
import { MODEL_ID, SERVICE_URL } from "./config";
import { buildClippingplanes } from "./utils/build-clipping-planes";
import { buildGrid } from "./utils/build-grid";
import { DragControls } from "three/examples/jsm/Addons.js";
export class MapScene {
private _scene: Scene;
private _dragControls: DragControls;
private _model: Group;
constructor(scene: Scene, model: Group, dragControls: DragControls) {
this._scene = scene;
this._dragControls = dragControls;
this._model = model;
}
static async create(container: HTMLElement, modelId: string) {
const { scene, model, dragControls } = await init(container, modelId);
return new MapScene(scene, model, dragControls);
}
get scene() {
return this._scene;
}
get model() {
return this._model;
}
toggleClippingBox() {
const box = this._scene?.getObjectByName("clipping-box");
if (box) {
// Set DragControls
if (box.visible) {
this._dragControls.enabled = false;
} else {
this._dragControls.enabled = true;
}
box.visible = !box.visible;
}
}
toggleLayerVisibility(layerName: string) {
const mesh = this._model.getObjectByName(layerName);
if (mesh) {
mesh.visible = !mesh.visible;
}
}
}
async function init(container: HTMLElement, modelId = MODEL_ID) {
const modelData = await getMetadata(SERVICE_URL + modelId);
const mappedFeatures = modelData.mappedfeatures;
const modelarea = modelData.modelarea;
const extent: Extent = {
xmin: modelarea.x.min,
xmax: modelarea.x.max,
ymin: modelarea.y.min,
ymax: modelarea.y.max,
zmin: modelarea.z.min,
zmax: modelarea.z.max,
};
const { renderer, scene, camera, controls } = buildScene(container, extent);
// Build the 3D model
const meshes = await buildMeshes(mappedFeatures);
const model = new Group();
model.add(...meshes);
model.name = "geologic-model";
scene.add(model);
// Build the clipping planes and add them to the scene
const { planes, dragControls } = buildClippingplanes(
renderer,
camera,
controls,
extent,
meshes,
scene
);
// 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.name = "coordinate-grid";
annotationsGroup.add(...annotations);
scene.add(gridHelper, annotationsGroup);
//const axesHelper = new AxesHelper(5);
//scene.add(axesHelper);
return { scene, model, dragControls };
}

View file

@ -2,7 +2,10 @@ import {
BufferAttribute, BufferAttribute,
BufferGeometry, BufferGeometry,
DoubleSide, DoubleSide,
EdgesGeometry,
Group, Group,
LineBasicMaterial,
LineSegments,
Mesh, Mesh,
MeshBasicMaterial, MeshBasicMaterial,
MeshStandardMaterial, MeshStandardMaterial,
@ -15,11 +18,7 @@ import {
Vector3, Vector3,
WebGLRenderer, WebGLRenderer,
} from "three"; } from "three";
import { import { DragControls, OrbitControls } from "three/examples/jsm/Addons.js";
ConvexGeometry,
DragControls,
OrbitControls,
} from "three/examples/jsm/Addons.js";
import { Extent } from "./build-scene"; import { Extent } from "./build-scene";
import earcut from "earcut"; import earcut from "earcut";
@ -30,11 +29,19 @@ enum Orientation {
} }
type PlaneMesh = Mesh<PlaneGeometry, MeshBasicMaterial, Object3DEventMap>; type PlaneMesh = Mesh<PlaneGeometry, MeshBasicMaterial, Object3DEventMap>;
type EdgeMesh = LineSegments<
EdgesGeometry<PlaneGeometry>,
LineBasicMaterial,
Object3DEventMap
>;
type PlaneMeshMap = { type PlaneMeshMap = {
[key in Orientation]: PlaneMesh; [key in Orientation]: PlaneMesh;
}; };
type EdgeMashMap = {
[key in Orientation]: EdgeMesh;
};
export function createClippingPlanes( export function buildClippingplanes(
renderer: WebGLRenderer, renderer: WebGLRenderer,
camera: PerspectiveCamera, camera: PerspectiveCamera,
orbitControls: OrbitControls, orbitControls: OrbitControls,
@ -60,13 +67,13 @@ export function createClippingPlanes(
}, },
]; ];
const planeMeshes: Mesh< const planeMeshes: PlaneMesh[] = [];
PlaneGeometry, const edgeMeshes: EdgeMesh[] = [];
MeshBasicMaterial,
Object3DEventMap
>[] = [];
const planes: Plane[] = []; const planes: Plane[] = [];
let planeMeshMap = {} as Partial<PlaneMeshMap>; const planeMeshMap = {} as Partial<PlaneMeshMap>;
const edgeMeshMap = {} as Partial<EdgeMashMap>;
// Create plane meshes
for (let p of planesData) { for (let p of planesData) {
let name; let name;
let planeCenter; let planeCenter;
@ -79,7 +86,7 @@ export function createClippingPlanes(
planeCenter = new Vector3( planeCenter = new Vector3(
-p.d, -p.d,
extent.ymax - width / 2, extent.ymax - width / 2,
extent.zmax - height / 2 extent.zmin + height / 2
); );
} else if (p.orientation === Orientation.Y) { } else if (p.orientation === Orientation.Y) {
name = Orientation.Y; name = Orientation.Y;
@ -88,7 +95,7 @@ export function createClippingPlanes(
planeCenter = new Vector3( planeCenter = new Vector3(
extent.xmax - width / 2, extent.xmax - width / 2,
-p.d, -p.d,
extent.zmax - height / 2 extent.zmin + height / 2
); );
} else { } else {
name = Orientation.Z; name = Orientation.Z;
@ -101,46 +108,65 @@ export function createClippingPlanes(
); );
} }
// Visual representation of the clipping plane // Plane is given in Hesse normal form: a * x + b* y + c * y + d = 0, where normal = (a, b, c) and d = d
// Plane is given in Hesse normal form
const plane = new Plane(p.normal, p.d); const plane = new Plane(p.normal, p.d);
// Dragging Mechanism // Visual representation of the clipping plane
const planeGeometry = new PlaneGeometry(width, height);
const planeMesh = new Mesh( const planeMesh = new Mesh(
new PlaneGeometry(width, height), planeGeometry,
new MeshBasicMaterial({ new MeshBasicMaterial({
visible: true, visible: true,
color: 0xff0000, color: 0xa92a4e,
transparent: true, transparent: true,
opacity: 0.1, opacity: 0.1,
side: DoubleSide, side: DoubleSide,
clipIntersection: false,
}) })
); );
planeMesh.name = name; planeMesh.name = name;
planeMesh.userData.plane = plane; planeMesh.userData.plane = plane;
// Create the edges geometry
const edgesGeometry = new EdgesGeometry(planeGeometry);
const edgesMaterial = new LineBasicMaterial({ color: 0xa92a4e });
const edges = new LineSegments(edgesGeometry, edgesMaterial);
// Translate meshes
planeMesh.position.set(planeCenter.x, planeCenter.y, planeCenter.z); planeMesh.position.set(planeCenter.x, planeCenter.y, planeCenter.z);
edges.position.set(planeCenter.x, planeCenter.y, planeCenter.z);
// Rotate meshes
if (p.orientation === Orientation.X) { if (p.orientation === Orientation.X) {
planeMesh.rotateY(Math.PI / 2); planeMesh.rotateY(Math.PI / 2);
planeMesh.rotateZ(Math.PI / 2); planeMesh.rotateZ(Math.PI / 2);
edges.rotateY(Math.PI / 2);
edges.rotateZ(Math.PI / 2);
} else if (p.orientation === Orientation.Y) { } else if (p.orientation === Orientation.Y) {
planeMesh.rotateX(Math.PI / 2); planeMesh.rotateX(Math.PI / 2);
edges.rotateX(Math.PI / 2);
} }
planeMeshes.push(planeMesh); planeMeshes.push(planeMesh);
edgeMeshes.push(edges);
planes.push(plane); planes.push(plane);
planeMeshMap[p.orientation] = planeMesh; planeMeshMap[p.orientation] = planeMesh;
edgeMeshMap[p.orientation] = edges;
} }
for (let pm of planeMeshes) { // Add meshes to the scene
// Let clipping planes clip each other const planeMeshGroup = new Group();
const clippingPlanes = planes.filter( planeMeshGroup.name = "clipping-planes";
(p) => !p.normal.equals(pm.userData.plane.normal) planeMeshGroup.add(...planeMeshes);
);
pm.material.clippingPlanes = clippingPlanes; const edgeMeshGroup = new Group();
} edgeMeshGroup.name = "clipping-plane-edges";
edgeMeshGroup.add(...edgeMeshes);
const clippingBox = new Group();
clippingBox.add(planeMeshGroup, edgeMeshGroup);
clippingBox.name = "clipping-box";
scene.add(clippingBox);
// Enable DragControls for the clipping planes // Enable DragControls for the clipping planes
const dragControls = new DragControls( const dragControls = new DragControls(
@ -149,15 +175,17 @@ export function createClippingPlanes(
renderer.domElement renderer.domElement
); );
dragControls.addEventListener("dragstart", () => { dragControls.addEventListener("dragstart", (event) => {
const object = event.object as PlaneMesh;
// Disable OrbitControls when dragging starts // Disable OrbitControls when dragging starts
orbitControls.enabled = false; orbitControls.enabled = false;
// Remove existing cap meshes // Remove existing cap meshes
let capMeshGroup = scene.getObjectByName("cap-mesh-group"); const capMeshGroupName = `cap-mesh-group-${object.name}`;
let capMeshGroup = scene.getObjectByName(capMeshGroupName);
while (capMeshGroup) { while (capMeshGroup) {
scene.remove(capMeshGroup); scene.remove(capMeshGroup);
capMeshGroup = scene.getObjectByName("cap-mesh-group"); capMeshGroup = scene.getObjectByName(capMeshGroupName);
} }
}); });
@ -171,7 +199,9 @@ export function createClippingPlanes(
const plane = event.object.userData.plane; const plane = event.object.userData.plane;
const width = object.geometry.parameters.width; const width = object.geometry.parameters.width;
const height = object.geometry.parameters.height; const height = object.geometry.parameters.height;
let orientation: Orientation;
if (object.name === Orientation.Z) { if (object.name === Orientation.Z) {
orientation = Orientation.Z;
// Fix rotation of dragged mesh // Fix rotation of dragged mesh
event.object.rotation.set(0, 0, 0); event.object.rotation.set(0, 0, 0);
@ -187,14 +217,28 @@ export function createClippingPlanes(
// Reset position of plane // Reset position of plane
plane.constant = newZ; plane.constant = newZ;
// Set position of dragged mesh // Set position of dragged meshes
object.position.x = extent.xmax - width / 2; object.position.x = extent.xmax - width / 2;
object.position.y = extent.ymax - height / 2; object.position.y = extent.ymax - height / 2;
object.position.z = newZ; object.position.z = newZ;
// Resize other meshes const edgeMesh = edgeMeshMap[Orientation.Z];
resizeMeshes(Orientation.Z, newZ, planeMeshMap as PlaneMeshMap, extent); if (edgeMesh) {
edgeMesh.position.x = extent.xmax - width / 2;
edgeMesh.position.y = extent.ymax - height / 2;
edgeMesh.position.z = newZ;
}
// Resize other meshes to disable dragging of clipped surface parts
resizeMeshes(
Orientation.Z,
newZ,
planeMeshMap as PlaneMeshMap,
edgeMeshMap as EdgeMashMap,
extent
);
} else if (object.name === Orientation.Y) { } else if (object.name === Orientation.Y) {
orientation = Orientation.Y;
// Fix rotation of dragged mesh // Fix rotation of dragged mesh
event.object.rotation.set(Math.PI / 2, 0, 0); event.object.rotation.set(Math.PI / 2, 0, 0);
@ -213,11 +257,26 @@ export function createClippingPlanes(
// Set position of dragged mesh // Set position of dragged mesh
object.position.x = extent.xmax - width / 2; object.position.x = extent.xmax - width / 2;
object.position.y = newY; object.position.y = newY;
object.position.z = extent.zmax - height / 2; object.position.z = extent.zmin + height / 2;
const edgeMesh = edgeMeshMap[Orientation.Y];
if (edgeMesh) {
edgeMesh.position.x = extent.xmax - width / 2;
edgeMesh.position.y = newY;
edgeMesh.position.z = extent.zmin + height / 2;
}
// Resize other meshes // Resize other meshes
resizeMeshes(Orientation.Y, newY, planeMeshMap as PlaneMeshMap, extent); resizeMeshes(
Orientation.Y,
newY,
planeMeshMap as PlaneMeshMap,
edgeMeshMap as EdgeMashMap,
extent
);
} else { } else {
orientation = Orientation.X;
// Fix rotation of dragged mesh // Fix rotation of dragged mesh
event.object.rotation.set(0, Math.PI / 2, Math.PI / 2); event.object.rotation.set(0, Math.PI / 2, Math.PI / 2);
@ -236,124 +295,178 @@ export function createClippingPlanes(
// Set position of dragged mesh // Set position of dragged mesh
object.position.x = newX; object.position.x = newX;
object.position.y = extent.ymax - width / 2; object.position.y = extent.ymax - width / 2;
object.position.z = extent.zmax - height / 2; object.position.z = extent.zmin + height / 2;
const edgeMesh = edgeMeshMap[Orientation.X];
if (edgeMesh) {
edgeMesh.position.x = newX;
edgeMesh.position.y = extent.ymax - width / 2;
edgeMesh.position.z = extent.zmin + height / 2;
}
// Resize other meshes // Resize other meshes
resizeMeshes(Orientation.X, newX, planeMeshMap as PlaneMeshMap, extent); resizeMeshes(
Orientation.X,
newX,
planeMeshMap as PlaneMeshMap,
edgeMeshMap as EdgeMashMap,
extent
);
} }
// Remove existing cap meshes // Remove existing cap meshes
let capMeshGroup = scene.getObjectByName("cap-mesh-group"); const capMeshGroupName = `cap-mesh-group-${object.name}`;
let capMeshGroup = scene.getObjectByName(capMeshGroupName);
while (capMeshGroup) { while (capMeshGroup) {
scene.remove(capMeshGroup); scene.remove(capMeshGroup);
capMeshGroup = scene.getObjectByName("cap-mesh-group"); capMeshGroup = scene.getObjectByName(capMeshGroupName);
} }
const capMeshes = generateCapMeshes(meshes, plane); // Generate new cap meshes
const capMeshes = generateCapMeshes(meshes, plane, planes, orientation);
// Add new cap meshes
if (capMeshes.length > 0) { if (capMeshes.length > 0) {
// Add new cap meshes
const newCapMeshGroup = new Group(); const newCapMeshGroup = new Group();
newCapMeshGroup.add(...capMeshes); newCapMeshGroup.add(...capMeshes);
newCapMeshGroup.name = "cap-mesh-group"; newCapMeshGroup.name = capMeshGroupName;
scene.add(newCapMeshGroup); scene.add(newCapMeshGroup);
} }
}); });
return { planeMeshes, planes }; return { planes, dragControls };
} }
function resizeMeshes( function resizeMeshes(
orientation: Orientation, orientation: Orientation,
newCoordinate: number, newCoordinate: number,
planeMeshes: PlaneMeshMap, planeMeshes: PlaneMeshMap,
edgeMeshes: EdgeMashMap,
extent: Extent extent: Extent
) { ) {
if (orientation === Orientation.X) { if (orientation === Orientation.X) {
// Resize y-clipping plane // Resize y-clipping plane
let planeMesh = planeMeshes[Orientation.Y]; let planeMesh = planeMeshes[Orientation.Y];
let edgeMesh = edgeMeshes[Orientation.Y];
let width = extent.xmax - newCoordinate; let width = extent.xmax - newCoordinate;
let height = planeMesh.geometry.parameters.height; let height = planeMesh.geometry.parameters.height;
let planeGeometry = new PlaneGeometry(width, height);
const y = planeMesh.position.y; const y = planeMesh.position.y;
planeMesh.geometry.dispose(); planeMesh.geometry.dispose();
planeMesh.geometry = new PlaneGeometry(width, height); planeMesh.geometry = planeGeometry;
planeMesh.position.set( planeMesh.position.set(
extent.xmax - width / 2, extent.xmax - width / 2,
y, y,
extent.zmax - height / 2 extent.zmin + height / 2
); );
edgeMesh.geometry.dispose();
edgeMesh.geometry = new EdgesGeometry(planeGeometry);
edgeMesh.position.set(extent.xmax - width / 2, y, extent.zmin + height / 2);
// Resize z-clipping-plane // Resize z-clipping-plane
planeMesh = planeMeshes[Orientation.Z]; planeMesh = planeMeshes[Orientation.Z];
edgeMesh = edgeMeshes[Orientation.Z];
width = extent.xmax - newCoordinate; width = extent.xmax - newCoordinate;
height = planeMesh.geometry.parameters.height; height = planeMesh.geometry.parameters.height;
planeGeometry = new PlaneGeometry(width, height);
const z = planeMesh.position.z; const z = planeMesh.position.z;
planeMesh.geometry.dispose(); planeMesh.geometry.dispose();
planeMesh.geometry = new PlaneGeometry(width, height); planeMesh.geometry = planeGeometry;
planeMesh.position.set( planeMesh.position.set(
extent.xmax - width / 2, extent.xmax - width / 2,
extent.ymax - height / 2, extent.ymax - height / 2,
z z
); );
edgeMesh.geometry.dispose();
edgeMesh.geometry = new EdgesGeometry(planeGeometry);
edgeMesh.position.set(extent.xmax - width / 2, extent.ymax - height / 2, z);
} else if (orientation === Orientation.Y) { } else if (orientation === Orientation.Y) {
// Resize x-clipping plane // Resize x-clipping plane
let planeMesh = planeMeshes[Orientation.X]; let planeMesh = planeMeshes[Orientation.X];
let edgeMesh = edgeMeshes[Orientation.X];
let width = extent.ymax - newCoordinate; let width = extent.ymax - newCoordinate;
let height = planeMesh.geometry.parameters.height; let height = planeMesh.geometry.parameters.height;
let planeGeometry = new PlaneGeometry(width, height);
const x = planeMesh.position.x; const x = planeMesh.position.x;
planeMesh.geometry.dispose(); planeMesh.geometry.dispose();
planeMesh.geometry = new PlaneGeometry(width, height); planeMesh.geometry = planeGeometry;
planeMesh.position.set( planeMesh.position.set(
x, x,
extent.ymax - width / 2, extent.ymax - width / 2,
extent.zmax - height / 2 extent.zmin + height / 2
); );
edgeMesh.geometry.dispose();
edgeMesh.geometry = new EdgesGeometry(planeGeometry);
edgeMesh.position.set(x, extent.ymax - width / 2, extent.zmin + height / 2);
// Resize z-clipping-plane // Resize z-clipping-plane
planeMesh = planeMeshes[Orientation.Z]; planeMesh = planeMeshes[Orientation.Z];
edgeMesh = edgeMeshes[Orientation.Z];
width = planeMesh.geometry.parameters.width; width = planeMesh.geometry.parameters.width;
height = extent.ymax - newCoordinate; height = extent.ymax - newCoordinate;
planeGeometry = new PlaneGeometry(width, height);
const z = planeMesh.position.z; const z = planeMesh.position.z;
planeMesh.geometry.dispose(); planeMesh.geometry.dispose();
planeMesh.geometry = new PlaneGeometry(width, height); planeMesh.geometry = planeGeometry;
planeMesh.position.set( planeMesh.position.set(
extent.xmax - width / 2, extent.xmax - width / 2,
extent.ymax - height / 2, extent.ymax - height / 2,
z z
); );
edgeMesh.geometry.dispose();
edgeMesh.geometry = new EdgesGeometry(planeGeometry);
edgeMesh.position.set(extent.xmax - width / 2, extent.ymax - height / 2, z);
} else if (orientation === Orientation.Z) { } else if (orientation === Orientation.Z) {
// Resize x-clipping-plane // Resize x-clipping-plane
let planeMesh = planeMeshes[Orientation.X]; let planeMesh = planeMeshes[Orientation.X];
let edgeMesh = edgeMeshes[Orientation.X];
let width = planeMesh.geometry.parameters.width; let width = planeMesh.geometry.parameters.width;
let height = newCoordinate - extent.zmin; let height = newCoordinate - extent.zmin;
let planeGeometry = new PlaneGeometry(width, height);
const x = planeMesh.position.x; const x = planeMesh.position.x;
planeMesh.geometry.dispose(); planeMesh.geometry.dispose();
planeMesh.geometry = new PlaneGeometry(width, height); planeMesh.geometry = planeGeometry;
planeMesh.position.set( planeMesh.position.set(
x, x,
extent.ymax - width / 2, extent.ymax - width / 2,
extent.zmax - height / 2 extent.zmin + height / 2
); );
edgeMesh.geometry.dispose();
edgeMesh.geometry = new EdgesGeometry(planeGeometry);
edgeMesh.position.set(x, extent.ymax - width / 2, extent.zmin + height / 2);
// Resize y-clipping plane // Resize y-clipping plane
planeMesh = planeMeshes[Orientation.Y]; planeMesh = planeMeshes[Orientation.Y];
edgeMesh = edgeMeshes[Orientation.Y];
width = planeMesh.geometry.parameters.width; width = planeMesh.geometry.parameters.width;
height = newCoordinate - extent.zmin; height = newCoordinate - extent.zmin;
planeGeometry = new PlaneGeometry(width, height);
const y = planeMesh.position.y; const y = planeMesh.position.y;
planeMesh.geometry.dispose(); planeMesh.geometry.dispose();
planeMesh.geometry = new PlaneGeometry(width, height); planeMesh.geometry = planeGeometry;
planeMesh.position.set( planeMesh.position.set(
extent.xmax - width / 2, extent.xmax - width / 2,
y, y,
extent.zmax - height / 2 extent.zmin + height / 2
); );
edgeMesh.geometry.dispose();
edgeMesh.geometry = new EdgesGeometry(planeGeometry);
edgeMesh.position.set(extent.xmax - width / 2, y, extent.zmin + height / 2);
} }
} }
// Extract contour and generate cap // Extract contour and generate cap
function generateCapMeshes(meshes: Mesh[], plane: Plane) { function generateCapMeshes(
meshes: Mesh[],
plane: Plane,
planes: Plane[],
orientation: Orientation
) {
const capMeshes: Mesh[] = []; const capMeshes: Mesh[] = [];
// Iterate over the list of geologic meshes
for (let mesh of meshes) { for (let mesh of meshes) {
const position = mesh.geometry.attributes.position.array; const position = mesh.geometry.attributes.position.array;
const indices = mesh.geometry.index ? mesh.geometry.index.array : null; const indices = mesh.geometry.index ? mesh.geometry.index.array : null;
@ -389,14 +502,20 @@ function generateCapMeshes(meshes: Mesh[], plane: Plane) {
} }
} }
// Intersection surface can be a multipolygon consisting of disconnected polygons
const polygons: Vector3[][] = buildPolygons(edges); const polygons: Vector3[][] = buildPolygons(edges);
// Clip cap surfaces with clipping planes
const clippingPlanes = planes.filter((p) => !p.normal.equals(plane.normal));
const offset = orientation === Orientation.Z ? 1 : -1;
const material = new MeshStandardMaterial({ const material = new MeshStandardMaterial({
color: (mesh.material as MeshStandardMaterial).color, color: (mesh.material as MeshStandardMaterial).color,
side: DoubleSide, side: DoubleSide,
polygonOffset: true, polygonOffset: true,
polygonOffsetFactor: -1, polygonOffsetFactor: offset,
polygonOffsetUnits: -1, polygonOffsetUnits: offset,
clippingPlanes,
}); });
const localMeshes = polygons.map((polygon) => { const localMeshes = polygons.map((polygon) => {
@ -405,7 +524,6 @@ function generateCapMeshes(meshes: Mesh[], plane: Plane) {
const capMesh = new Mesh(geometry, material); const capMesh = new Mesh(geometry, material);
// Offset mesh to avoid flickering // Offset mesh to avoid flickering
const offset = -1;
const normal = plane.normal.clone().multiplyScalar(offset); const normal = plane.normal.clone().multiplyScalar(offset);
const positionAttr = capMesh.geometry.attributes.position; const positionAttr = capMesh.geometry.attributes.position;
@ -518,13 +636,6 @@ function triangulatePolygon(vertices: Vector3[], plane: Plane) {
return geometry; return geometry;
} }
// Compute the centroid of a list of 2D vertices
function computeCentroid(vertices: Vector2[]): Vector2 {
const centroid = new Vector2();
vertices.forEach((v) => centroid.add(v));
return centroid.divideScalar(vertices.length);
}
// Function to find the intersection point between an edge and a plane // Function to find the intersection point between an edge and a plane
function intersectEdge(v1: Vector3, v2: Vector3, d1: number, d2: number) { function intersectEdge(v1: Vector3, v2: Vector3, d1: number, d2: number) {
const t = d1 / (d1 - d2); const t = d1 / (d1 - d2);

View file

@ -4,12 +4,8 @@ import {
DoubleSide, DoubleSide,
Mesh, Mesh,
MeshStandardMaterial, MeshStandardMaterial,
Plane,
} from "three"; } from "three";
import { uniforms } from "./uniforms";
import { shader } from "./shader";
import { fetchTriangleIndices } from "./fetch-triangle-indices"; import { fetchTriangleIndices } from "./fetch-triangle-indices";
import { fetchVertices } from "./fetch-vertices"; import { fetchVertices } from "./fetch-vertices";
import { TRIANGLE_INDICES_URL, VERTICES_URL } from "../config"; import { TRIANGLE_INDICES_URL, VERTICES_URL } from "../config";
@ -21,6 +17,17 @@ interface MappedFeature {
preview: { legend_color: string; legend_text: string }; preview: { legend_color: string; legend_text: string };
} }
export async function buildMeshes(mappedFeatures: MappedFeature[]) {
const meshes = [];
for (let i = 0; i < mappedFeatures.length; i++) {
const layerData = mappedFeatures[i];
const mesh = await buildMesh(layerData);
meshes.push(mesh);
}
return meshes;
}
async function buildMesh(layerData: MappedFeature) { async function buildMesh(layerData: MappedFeature) {
const color = `#${layerData.preview.legend_color}`; const color = `#${layerData.preview.legend_color}`;
const name = layerData.preview.legend_text; const name = layerData.preview.legend_text;
@ -43,23 +50,13 @@ async function buildMesh(layerData: MappedFeature) {
const material = new MeshStandardMaterial({ const material = new MeshStandardMaterial({
color: color, color: color,
metalness: 0.1, metalness: 0.0,
roughness: 0.75, roughness: 0.75,
flatShading: true, flatShading: true,
side: DoubleSide, side: DoubleSide,
wireframe: false, wireframe: false,
clipIntersection: false,
}); });
// material.onBeforeCompile = (materialShader) => {
// materialShader.uniforms.clippingLow = uniforms.clipping.clippingLow;
// materialShader.uniforms.clippingHigh = uniforms.clipping.clippingHigh;
// materialShader.uniforms.clippingScale = uniforms.clipping.clippingScale;
// materialShader.vertexShader = shader.vertexMeshStandard;
// materialShader.fragmentShader = shader.fragmentClippingMeshStandard;
// };
const mesh = new Mesh(geometry, material); const mesh = new Mesh(geometry, material);
mesh.name = name; mesh.name = name;
mesh.userData.layerId = geomId; mesh.userData.layerId = geomId;
@ -68,16 +65,3 @@ async function buildMesh(layerData: MappedFeature) {
return mesh; return mesh;
} }
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);
meshes.push(mesh);
}
}
return meshes;
}

View file

@ -17,7 +17,7 @@ let controls: OrbitControls;
let renderer: WebGLRenderer; let renderer: WebGLRenderer;
let camera: PerspectiveCamera; let camera: PerspectiveCamera;
let scene: Scene; let scene: Scene;
export async function buildScene(container: HTMLElement, extent: Extent) { export function buildScene(container: HTMLElement, extent: Extent) {
const maxSize = getMaxSize(extent); const maxSize = getMaxSize(extent);
const center = getCenter3D(extent); const center = getCenter3D(extent);
@ -31,7 +31,7 @@ export async function buildScene(container: HTMLElement, extent: Extent) {
maxSize * 25 maxSize * 25
); );
camera.position.set(center.x, center.y, extent.zmax + 150000); camera.position.set(center.x, center.y - 125000, extent.zmax + 100000);
camera.lookAt(center); camera.lookAt(center);
renderer = new WebGLRenderer({ renderer = new WebGLRenderer({

View file

@ -1,5 +0,0 @@
let lastId = 0;
export function stamp(obj: any) {
return obj._id ?? (obj._id = ++lastId);
}

View file

@ -1,15 +0,0 @@
export async function getMetadata(serviceUrl: string) {
const response = await fetch(serviceUrl, {
method: "GET",
mode: "cors",
headers: {
"Content-Type": "application/json",
},
});
if (response.ok) {
return response.json();
} else {
throw new Error("HTTP error status: " + response.status);
}
}

View file

@ -1,59 +0,0 @@
import { AxesHelper, Group } from "three";
import { buildMeshes } from "./build-meshes";
import { Extent, buildScene } from "./build-scene";
import { getMetadata } from "./get-metadata";
import { MODEL_ID, SERVICE_URL } from "../config";
import { createClippingPlanes } from "./build-clipping-plane";
import { buildGrid } from "./build-grid";
export async function init(container: HTMLElement) {
const modelData = await getMetadata(SERVICE_URL + MODEL_ID);
const mappedFeatures = modelData.mappedfeatures;
const modelarea = modelData.modelarea;
const extent: Extent = {
xmin: modelarea.x.min,
xmax: modelarea.x.max,
ymin: modelarea.y.min,
ymax: modelarea.y.max,
zmin: modelarea.z.min,
zmax: modelarea.z.max,
};
const { renderer, scene, camera, controls } = await buildScene(
container,
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,
meshes,
scene
);
scene.add(...planeMeshes);
// 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);
scene.add(gridHelper, annotationsGroup);
//const axesHelper = new AxesHelper(5);
//scene.add(axesHelper);
}

View file

@ -1,254 +0,0 @@
export const shader = {
vertex: `
uniform vec3 color;
varying vec3 pixelNormal;
void main() {
pixelNormal = normal;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
vertexClipping: `
uniform vec3 color;
uniform vec3 clippingLow;
uniform vec3 clippingHigh;
varying vec3 pixelNormal;
varying vec4 worldPosition;
varying vec3 camPosition;
varying vec3 vNormal;
varying vec3 vPosition;
varying vec2 vUv;
void main() {
vUv = uv;
vec4 vPos = modelViewMatrix * vec4( position, 1.0 );
vPosition = vPos.xyz;
vNormal = normalMatrix * normal;
pixelNormal = normal;
worldPosition = modelMatrix * vec4( position, 1.0 );
camPosition = cameraPosition;
// gl_Position = projectionMatrix * vPos;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragment: `
uniform vec3 color;
varying vec3 pixelNormal;
varying vec3 vNormal;
varying vec3 vPosition;
// uniform vec3 spotLightPosition; // in world space
// uniform vec3 clight;
// uniform vec3 cspec;
// uniform float roughness;
const float PI = 3.14159;
void main( void ) {
float shade = (
3.0 * pow ( abs ( pixelNormal.y ), 2.0 )
+ 2.0 * pow ( abs ( pixelNormal.z ), 2.0 )
+ 1.0 * pow ( abs ( pixelNormal.x ), 2.0 )
) / 3.0;
gl_FragColor = vec4( color * shade, 1.0 );
//gl_FragColor = vec4(color, 1.0);
// vec4 lPosition = viewMatrix * vec4( spotLightPosition, 1.0 );
// vec3 l = normalize(lPosition.xyz - vPosition.xyz);
// vec3 n = normalize( vNormal ); // interpolation destroys normalization, so we have to normalize
// vec3 v = normalize( -vPosition);
// vec3 h = normalize( v + l);
// // small quantity to prevent divisions by 0
// float nDotl = max(dot( n, l ),0.000001);
// float lDoth = max(dot( l, h ),0.000001);
// float nDoth = max(dot( n, h ),0.000001);
// float vDoth = max(dot( v, h ),0.000001);
// float nDotv = max(dot( n, v ),0.000001);
// vec3 specularBRDF = FSchlick(lDoth)*GSmith(nDotv,nDotl)*DGGX(nDoth,roughness*roughness)/(4.0*nDotl*nDotv);
// vec3 outRadiance = (PI* clight * nDotl * specularBRDF);
// gl_FragColor = vec4(pow( outRadiance, vec3(1.0/2.2)), 1.0);
}`,
fragmentClippingFront: `
uniform sampler2D map;
uniform vec3 color;
uniform vec3 clippingLow;
uniform vec3 clippingHigh;
uniform float clippingScale;
uniform float percent;
varying vec3 pixelNormal;
varying vec4 worldPosition;
varying vec3 camPosition;
varying vec2 vUv;
void main( void ) {
float shade = (
3.0 * pow ( abs ( pixelNormal.y ), 2.0 )
+ 2.0 * pow ( abs ( pixelNormal.z ), 2.0 )
+ 1.0 * pow ( abs ( pixelNormal.x ), 2.0 )
) / 3.0;
if (
worldPosition.x < clippingLow.x
|| worldPosition.x > clippingHigh.x
|| worldPosition.y < clippingLow.y
|| worldPosition.y > clippingHigh.y
|| worldPosition.z < (clippingLow.z * clippingScale)
|| worldPosition.z > (clippingHigh.z * clippingScale)
) {
discard;
} else {
gl_FragColor = texture2D(map, vUv);
gl_FragColor.a = percent;
}
}`,
vertexMeshStandard: `
#define STANDARD
varying vec3 vViewPosition;
varying vec4 worldPosition;
#include <common>
#include <uv_pars_vertex>
#include <color_pars_vertex>
#include <fog_pars_vertex>
#include <shadowmap_pars_vertex>
#include <logdepthbuf_pars_vertex>
#include <clipping_planes_pars_vertex>
void main() {
#include <uv_vertex>
#include <color_vertex>
#include <beginnormal_vertex>
#include <defaultnormal_vertex>
#include <begin_vertex>
#include <project_vertex>
#include <logdepthbuf_vertex>
#include <clipping_planes_vertex>
vViewPosition = -mvPosition.xyz;
worldPosition = modelMatrix * vec4(position, 1.0);
#include <shadowmap_vertex>
#include <fog_vertex>
}
`,
fragmentClippingMeshStandard: `
#define STANDARD
uniform vec3 diffuse;
uniform vec3 emissive;
uniform float roughness;
uniform float metalness;
uniform float opacity;
varying vec3 vViewPosition;
varying vec4 worldPosition;
uniform vec3 clippingLow;
uniform vec3 clippingHigh;
uniform float clippingScale;
#include <common>
#include <packing>
#include <dithering_pars_fragment>
#include <color_pars_fragment>
#include <uv_pars_fragment>
#include <map_pars_fragment>
#include <alphamap_pars_fragment>
#include <aomap_pars_fragment>
#include <emissivemap_pars_fragment>
#include <bsdfs>
#include <transmission_pars_fragment>
#include <envmap_physical_pars_fragment>
#include <fog_pars_fragment>
#include <lights_pars_begin>
#include <lights_physical_pars_fragment>
#include <shadowmap_pars_fragment>
#include <normalmap_pars_fragment>
#include <roughnessmap_pars_fragment>
#include <metalnessmap_pars_fragment>
#include <logdepthbuf_pars_fragment>
#include <clipping_planes_pars_fragment>
void main() {
#include <clipping_planes_fragment>
vec4 diffuseColor = vec4(diffuse, opacity);
ReflectedLight reflectedLight = ReflectedLight(vec3(0.0), vec3(0.0), vec3(0.0), vec3(0.0));
vec3 totalEmissiveRadiance = emissive;
#ifdef TRANSMISSION
float totalTransmission = transmission;
float thicknessFactor = thickness;
#endif
#include <logdepthbuf_fragment>
#include <map_fragment>
#include <color_fragment>
#include <alphamap_fragment>
#include <alphatest_fragment>
#include <roughnessmap_fragment>
#include <metalnessmap_fragment>
#include <normal_fragment_begin>
#include <normal_fragment_maps>
#include <emissivemap_fragment>
vec3 rawDiffuseColor = diffuseColor.rgb;
#include <transmission_fragment>
// Lighting calculations
#include <lights_physical_fragment>
#include <lights_fragment_maps>
// Ambient occlusion
#include <aomap_fragment>
vec3 outgoingLight = reflectedLight.directDiffuse
+ reflectedLight.indirectDiffuse
+ reflectedLight.directSpecular
+ reflectedLight.indirectSpecular
+ totalEmissiveRadiance;
// Clipping logic
if (any(greaterThan(worldPosition.xyz, clippingHigh)) || any(lessThan(worldPosition.xyz, clippingLow))) {
discard;
}
gl_FragColor = vec4(outgoingLight, diffuseColor.a);
}
`,
invisibleVertexShader: `
void main() {
vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
gl_Position = projectionMatrix * mvPosition;
}`,
invisibleFragmentShader: `
void main( void ) {
gl_FragColor = vec4( 0.0, 0.0, 0.0, 1.0 );
discard;
}`,
};

View file

@ -1,19 +0,0 @@
import { Vector3, Color } from "three";
export const uniforms = {
clipping: {
// light blue
color: { value: new Color(0x3d9ecb) },
clippingLow: { value: new Vector3(0, 0, 0) },
clippingHigh: { value: new Vector3(0, 0, 0) },
// additional parameter for scaling
clippingScale: { value: 1.0 },
// topography
map: { value: null },
percent: { value: 1 },
},
caps: {
// red
color: { value: new Color(0xf83610) },
},
};

View file

@ -16,3 +16,19 @@ export function getCenter3D(extent: Extent) {
(extent.zmax + extent.zmin) / 2 (extent.zmax + extent.zmin) / 2
); );
} }
export async function getMetadata(serviceUrl: string) {
const response = await fetch(serviceUrl, {
method: "GET",
mode: "cors",
headers: {
"Content-Type": "application/json",
},
});
if (response.ok) {
return response.json();
} else {
throw new Error("HTTP error status: " + response.status);
}
}

371
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"earcut": "^3.0.1", "earcut": "^3.0.1",
"flowbite-react": "^0.10.2",
"next": "15.1.7", "next": "15.1.7",
"proj4": "^2.15.0", "proj4": "^2.15.0",
"react": "^19.0.0", "react": "^19.0.0",