Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as indexExports from "./index";

// d3 seems to have issues in Jest.
// We don't need it for these tests, so just mock it.
jest.mock("d3", () => jest.fn());
jest.mock("d3-scale-chromatic", () => jest.fn());

describe("index.ts", () => {
const expectedExports = [
"SurfaceMesh",
"MeshColors",
"Surface",
"minMax",
"ViewerClient",
"colorMaps",
"colorInterpolates",
"Legend",
].sort();

it("should export the expected exports", () => {
const actualExports = Object.keys(indexExports).sort();

expect(actualExports).toEqual(expectedExports);
});

it("should export the expected number of exports", () => {
const actualExports = Object.keys(indexExports);

expect(actualExports.length).toEqual(expectedExports.length);
});
});
233 changes: 5 additions & 228 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,228 +1,5 @@
import * as THREE from "three";
import CameraControls from "camera-controls";
import { Legend } from "./colormaps/legend";
import { Surface } from "./surfaceModels";
import { surfaceToMesh } from "./utils";
import { ColorInterpolateName, colorInterpolates } from "./colormaps/d3ColorSchemes";

export type SerializableViewerState = {
map?: number[];
mesh?: {
vertices: number[];
faces: number[];
};
};

export class ViewerClient {
public controls: CameraControls;
public renderer: THREE.WebGLRenderer;

private elemViewer: HTMLElement;
private scene: THREE.Scene;
private camera: THREE.PerspectiveCamera;
private raycaster: THREE.Raycaster;

private legend?: Legend;

public constructor(elemViewer: HTMLElement, elemLegend?: HTMLElement) {
if (elemLegend) {
this.legend = new Legend(elemLegend);
}

this.elemViewer = elemViewer;

this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(
60,
window.innerWidth / window.innerHeight,
0.1,
1000,
);
this.camera.position.set(-150, 100, -100);

this.raycaster = new THREE.Raycaster();

this.renderer = new THREE.WebGLRenderer();
this.elemViewer.innerHTML = "";
this.renderer.setSize(
this.elemViewer.clientWidth,
this.elemViewer.clientHeight,
);
this.elemViewer.appendChild(this.renderer.domElement);

CameraControls.install({ THREE: THREE });
this.controls = new CameraControls(this.camera, this.renderer.domElement);
this.controls.minZoom = 0.1;

const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
this.scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 0.4);
this.scene.add(directionalLight);

const clock = new THREE.Clock();

const animate = () => {
if (!this.camera) {
return;
}

const delta = clock.getDelta();
this.controls.update(delta);

requestAnimationFrame(animate);

const cameraPosition = this.camera.position;
const targetPosition = this.controls.getTarget(new THREE.Vector3());

directionalLight.position.set(
cameraPosition.x + (cameraPosition.x - targetPosition.x) * 0.2,
cameraPosition.y + (cameraPosition.y - targetPosition.y) * 0.2,
cameraPosition.z + (cameraPosition.z - targetPosition.z) * 4.0,
);

this.render();
};

animate();
window.addEventListener("resize", () => this.onWindowResize(), false);
}

private render(): void {
this.renderer.render(this.scene, this.camera);
}

public onWindowResize() {
this.camera.aspect =
this.elemViewer.clientWidth / this.elemViewer.clientHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(
this.elemViewer.clientWidth,
this.elemViewer.clientHeight,
);
this.render();
}

public setBackgroundColor(color: string): void {
this.renderer.setClearColor(color);
}

public setAlpha(alpha: number): void {
this.renderer.setClearAlpha(alpha);
}

public setTarget(targetName: string): undefined {
targetName = targetName.toLowerCase();
let target: THREE.Vector3;
if (targetName === "origin") {
target = new THREE.Vector3(0, 0, 0);
} else if (targetName === "center") {
const box = new THREE.Box3().setFromObject(this.scene);
target = box.getCenter(new THREE.Vector3());
} else {
console.warn("Unknown orbit point: " + targetName);
return undefined;
}
this.controls.setTarget(target.x, target.y, target.z);
}

public addListener(
eventName: string,
callable: (
evt: Event,
intersects?: THREE.Intersection<THREE.Object3D<THREE.Event>>[],
) => void,
): void {
const raycastEvents = [
"click",
"dblclick",
"mousedown",
"mouseup",
"touchstart",
"touchend",
] as const;
const eventNameLower: string = eventName.toLowerCase();

if (!(raycastEvents as unknown as string[]).includes(eventNameLower)) {
this.elemViewer.addEventListener(eventNameLower, callable, false);
return;
}

this.elemViewer.addEventListener(
eventNameLower as (typeof raycastEvents)[number],
(event: MouseEvent | TouchEvent) => {
const rect = this.elemViewer.getBoundingClientRect();
const mice = getClicks(event, rect);

const intersects = [];
for (let i = 0; i < mice.length; i++) {
this.raycaster.setFromCamera(mice[i], this.camera);
const inter = this.raycaster.intersectObjects(this.getModels());
intersects.push(inter[0]);
}
callable(event, intersects);
},
);
}

public addModel(surface: Surface): THREE.Mesh {
const obj = surfaceToMesh(surface);
this.scene.add(obj);
return obj;
}

public getModels(): THREE.Mesh[] {
return this.scene.children.filter(
(child) => child.type === "Mesh",
) as THREE.Mesh[];
}

public deleteModel(surface: THREE.Mesh): void {
this.scene.remove(surface);
}

public updateLegend(
colorMapName: ColorInterpolateName,
colorLimits: [number, number],
title?: string,
): void {

if (!this.legend) {
return;
}
this.legend.update(colorLimits[0], colorLimits[1], colorInterpolates[colorMapName], title);
}

public dispose(): void {
window.removeEventListener("resize", this.onWindowResize);
}
}

/**
* Returns an array of normalized mouse/touch coordinates based on the given event and rectangle.
* @param event - The mouse/touch event.
* @param rect - The rectangle of the element.
* @returns An array of normalized mouse coordinates.
*/
function getClicks(event: MouseEvent | TouchEvent, rect: DOMRect) {
const clicks: THREE.Vector2[] = [];

if (event instanceof TouchEvent) {
for (let i = 0; i < event.touches.length; i++) {
const eventTouch = event.touches[i];
const touch = new THREE.Vector2();
touch.x = ((eventTouch.clientX - rect.left) / rect.width) * 2 - 1;
touch.y = -((eventTouch.clientY - rect.top) / rect.height) * 2 + 1;
clicks.push(touch);
}
}

if (event instanceof MouseEvent) {
const mouse = new THREE.Vector2();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
clicks.push(mouse);
}

return clicks;
}
export { SurfaceMesh, MeshColors, Surface } from "./surfaceModels";
export { minMax } from "./utils";
export { ViewerClient } from "./viewer";
export { colorMaps, colorInterpolates } from "./colormaps/d3ColorSchemes";
export { Legend } from "./colormaps/legend";
4 changes: 2 additions & 2 deletions src/surfaceModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ export class MeshColors {

export class Surface {
public mesh: SurfaceMesh;
public colors: MeshColors;
public colors?: MeshColors;

constructor(mesh: SurfaceMesh, colors: MeshColors) {
constructor(mesh: SurfaceMesh, colors?: MeshColors) {
this.mesh = mesh;
this.colors = colors;
}
Expand Down
13 changes: 12 additions & 1 deletion src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ describe("minMax", () => {
});

describe("surfaceToMesh", () => {
it("should convert a Surface object to a THREE.Mesh object", () => {
it("should convert a Surface object with meshcolors to a THREE.Mesh object", () => {
// @ts-expect-error because Surface is mocked.
const surface = new Surface();

Expand All @@ -57,4 +57,15 @@ describe("surfaceToMesh", () => {
expect(mesh).toBeDefined();
expect(mesh instanceof THREE.Mesh).toBe(true);
});

it("should convert a Surface object without meshcolors to a THREE.Mesh object", () => {
// @ts-expect-error because Surface is mocked.
const surface = new Surface();
surface.colors = undefined;

const mesh = surfaceToMesh(surface);

expect(mesh).toBeDefined();
expect(mesh instanceof THREE.Mesh).toBe(true);
});
});
8 changes: 5 additions & 3 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ export function surfaceToMesh(surface: Surface): THREE.Mesh {
geometry.computeVertexNormals();

let material: THREE.MeshLambertMaterial;
const colors = surface.colors.colors;
if (colors) {
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
if (surface.colors) {
geometry.setAttribute(
"color",
new THREE.BufferAttribute(surface.colors.colors, 3),
);
material = new THREE.MeshLambertMaterial({
vertexColors: true,
});
Expand Down
Loading