"...git@transfer.hft-stuttgart.de:energy-ade/ade-xjc.git" did not exist on "32887a8257ee02a01fcad2e011857af41eb2d66b"
Commit 5ba8de0d authored by Matthias Betz's avatar Matthias Betz
Browse files

add viewer module with camera and shaders



Co-Authored-By: default avatarClaude Opus 5.5 (1M context) <noreply@anthropic.com>
parent 20b376f5
// Orbit camera around a centre point. distance is negative: the eye is
// behind the centre along the viewing direction.
import { mat4, vec2, vec3 } from '../vendor.js';
import { clipPlanes, FIELD_OF_VIEW, panPerPixel, zoomDistance } from './navigation.js';
export class Camera {
constructor(shader, gl) {
this.gl = gl;
this.uniformLocation = gl.getUniformLocation(shader, 'projViewModel');
this.up = vec3.create();
this.up[2] = 1;
this.distance = 5;
this.eyepos = vec3.create();
this.eyepos[0] = this.distance;
this.centerPos = vec3.create();
this.projMatrix = mat4.create();
this.viewMatrix = mat4.create();
this.projViewMatrix = mat4.create();
this.rotateAroundZ = 0;
this.origin = vec3.create();
this.aspectRatio = 1;
this.viewportHeight = 1;
}
// width and height in device pixels, cssHeight in CSS pixels (the unit of
// mouse movements).
reshape(width, height, cssHeight) {
this.aspectRatio = width / height;
this.viewportHeight = cssHeight;
this.updateMatrix();
}
// The projection depends on the distance (see clipPlanes), so it is
// rebuilt with every view change.
updateMatrix() {
const planes = clipPlanes(Math.abs(this.distance));
mat4.perspective(this.projMatrix, FIELD_OF_VIEW, this.aspectRatio, planes.near, planes.far);
mat4.lookAt(this.viewMatrix, this.eyepos, this.centerPos, this.up);
mat4.multiply(this.projViewMatrix, this.projMatrix, this.viewMatrix);
this.gl.uniformMatrix4fv(this.uniformLocation, false, this.projViewMatrix);
}
rotate(dragDiffX, dragDiffY) {
const rotationX = -dragDiffX * 1.0 / 500;
let rotationZ = -dragDiffY * 1.0 / 500;
const tempRotationValue = this.rotateAroundZ + rotationZ;
if (tempRotationValue < -Math.PI / 2 + 0.0001 || tempRotationValue > Math.PI / 2 - 0.0001) {
// too close to 90 degree, stop rotation
rotationZ = 0;
}
this.rotateAroundZ += rotationZ;
const res = vec3.create();
vec3.sub(res, this.eyepos, this.centerPos);
vec3.rotateZ(res, res, this.origin, rotationX);
const rotAxis = vec3.fromValues(res[0], res[1], res[2] + 5);
vec3.cross(rotAxis, rotAxis, res);
vec3.normalize(rotAxis, rotAxis);
this.rotateAroundAxis(res, rotationZ, rotAxis[0], rotAxis[1], rotAxis[2]);
vec3.add(this.eyepos, this.centerPos, res);
this.updateMatrix();
}
rotateAroundAxis(vector, angle, aX, aY, aZ) {
const hangle = angle * 0.5;
const sinAngle = Math.sin(hangle);
const qx = aX * sinAngle, qy = aY * sinAngle, qz = aZ * sinAngle;
const qw = Math.cos(hangle);
const w2 = qw * qw, x2 = qx * qx, y2 = qy * qy, z2 = qz * qz, zw = qz * qw;
const xy = qx * qy, xz = qx * qz, yw = qy * qw, yz = qy * qz, xw = qx * qw;
const x = vector[0], y = vector[1], z = vector[2];
vector[0] = (w2 + x2 - z2 - y2) * x + (-zw + xy - zw + xy) * y + (yw + xz + xz + yw) * z;
vector[1] = (xy + zw + zw + xy) * x + (y2 - z2 + w2 - x2) * y + (yz + yz - xw - xw) * z;
vector[2] = (xz - yw + xz - yw) * x + (yz + yz + xw + xw) * y + (z2 - y2 - x2 + w2) * z;
}
// Moves the camera so that it looks at center, keeping its direction and distance.
lookAt(center) {
const shift = vec3.create();
vec3.sub(shift, center, this.centerPos);
vec3.add(this.centerPos, this.centerPos, shift);
vec3.add(this.eyepos, this.eyepos, shift);
this.updateMatrix();
}
setDistance(distance) {
this.distance = distance;
const res = vec3.create();
vec3.subtract(res, this.centerPos, this.eyepos);
vec3.normalize(res, res);
vec3.scale(res, res, this.distance);
vec3.add(this.eyepos, res, this.centerPos);
this.updateMatrix();
}
// delta and deltaMode as in a WheelEvent; zoomDistance works with positive distances.
scroll(delta, deltaMode) {
this.setDistance(-zoomDistance(-this.distance, delta, deltaMode));
}
// Pans parallel to the ground; the speed grows with the distance so that
// the target stays under the mouse (see panPerPixel).
move(dragDiffX, dragDiffY) {
const perPixel = panPerPixel(Math.abs(this.distance), this.viewportHeight);
const res = vec3.create();
vec3.sub(res, this.centerPos, this.eyepos);
const dir = vec2.fromValues(res[0], res[1]);
vec2.normalize(dir, dir);
const yDrag = vec2.create();
vec2.scale(yDrag, dir, dragDiffY * perPixel);
vec3.add(this.centerPos, this.centerPos, vec3.fromValues(yDrag[0], yDrag[1], 0));
vec3.add(this.eyepos, this.eyepos, vec3.fromValues(yDrag[0], yDrag[1], 0));
const temp = dir[0];
dir[0] = dir[1];
dir[1] = -temp;
vec2.scale(dir, dir, -dragDiffX * perPixel);
vec3.add(this.centerPos, this.centerPos, vec3.fromValues(dir[0], dir[1], 0));
vec3.add(this.eyepos, this.eyepos, vec3.fromValues(dir[0], dir[1], 0));
this.updateMatrix();
}
}
// Shader sources. The attribute locations are bound by createProgram
// (position = 0, color = 1).
export const VERTEX_SHADER = `#version 300 es
precision mediump float;
in vec3 position;
in vec3 color;
uniform mat4 projViewModel;
out vec3 interpolatedColor;
void main() {
gl_Position = projViewModel * vec4(position, 1);
interpolatedColor = color;
}
`;
export const FRAGMENT_SHADER = `#version 300 es
precision mediump float;
in vec3 interpolatedColor;
out vec4 outputColor;
void main() {
outputColor = vec4(interpolatedColor, 1);
}
`;
// The 3D view: canvas, resize, mouse input, redraws and the GPU data of all
// layers. Geometry arrives through addBlock(); filters and selection are read
// from the store.
import { canvasPixelSize, createProgram, createScene, deleteScene, drawScene, updateBlock } from './renderer.js';
import { createDragTracker, fitDistance } from './navigation.js';
import { Camera } from './camera.js';
import { FRAGMENT_SHADER, VERTEX_SHADER } from './shaders.js';
import { isVisible } from '../app/state.js';
const CLEAR_COLOR = new Float32Array([0.9411765, 1.0, 1.0, 1.0]);
const CLEAR_DEPTH = new Float32Array([1.0]);
// Redraws while blocks arrive during loading. Each redraw draws everything
// loaded so far and blocks the loader meanwhile, so redraws are spaced to
// take at most about 1/LOADING_REDRAW_SHARE of the time.
const LOADING_REDRAW_MIN_INTERVAL_MS = 1000;
const LOADING_REDRAW_SHARE = 20;
export function createViewer(container, store) {
const canvas = document.createElement('canvas');
canvas.className = 'viewer-canvas';
container.append(canvas);
const gl = canvas.getContext('webgl2', { antialias: true });
if (!gl) {
showWebGL2Missing(container, canvas);
return { available: false, addBlock() {}, finishLayer() {}, requestRedraw() {}, get camera() { return undefined; } };
}
const program = createProgram(gl, VERTEX_SHADER, FRAGMENT_SHADER);
gl.useProgram(program);
gl.enable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
const scenes = new Map(); // layerId -> scene
const drag = createDragTracker();
let camera;
let userMovedCamera = false;
// Canvas size in device pixels (drawing) and its height in CSS pixels (mouse).
let width = 0;
let height = 0;
let cssHeight = 0;
let framePending = false;
let loadingRedrawPending = false;
let lastLoadingRedraw = 0;
let lastRedrawDuration = 0;
const lodVisible = lod => isVisible(store.get(), 'lod', lod);
function redraw() {
const start = performance.now();
gl.viewport(0, 0, width, height);
gl.clearBufferfv(gl.COLOR, 0, CLEAR_COLOR);
gl.clearBufferfv(gl.DEPTH, 0, CLEAR_DEPTH);
if (camera !== undefined) {
for (const scene of scenes.values()) {
drawScene(gl, scene, lodVisible);
}
}
lastRedrawDuration = performance.now() - start;
}
// At most one redraw per frame, e.g. after a filter change.
function requestRedraw() {
if (framePending) {
return;
}
framePending = true;
requestAnimationFrame(() => {
framePending = false;
redraw();
});
}
function scheduleLoadingRedraw() {
if (loadingRedrawPending) {
return;
}
loadingRedrawPending = true;
const interval = Math.max(LOADING_REDRAW_MIN_INTERVAL_MS, LOADING_REDRAW_SHARE * lastRedrawDuration);
const delay = Math.max(0, lastLoadingRedraw + interval - performance.now());
setTimeout(() => {
requestAnimationFrame(() => {
loadingRedrawPending = false;
redraw();
lastLoadingRedraw = performance.now();
});
}, delay);
}
// Places the camera on summary.center at the distance from which the
// whole bounding box fits into the view.
function centerCamera(summary) {
camera = new Camera(program, gl);
camera.reshape(width, height, cssHeight);
camera.lookAt(summary.center);
camera.setDistance(-fitDistance(summary.bbox.getDiagonalLength(), width / height));
camera.rotate(Math.PI / 2 * 500, 300);
}
canvas.addEventListener('contextmenu', event => event.preventDefault());
canvas.addEventListener('wheel', event => {
if (camera === undefined) {
return;
}
event.preventDefault();
userMovedCamera = true;
camera.scroll(event.deltaY, event.deltaMode);
redraw();
}, { passive: false });
// A drag starts only on the canvas (left button rotates, right button pans)
// and keeps the pointer captured, so it continues outside the canvas and
// always ends on release.
canvas.addEventListener('pointerdown', event => {
if (camera === undefined) {
return;
}
const mode = event.button === 0 ? 'rotate' : event.button === 2 ? 'pan' : null;
if (mode === null) {
return;
}
event.preventDefault();
canvas.setPointerCapture(event.pointerId);
drag.start(event.clientX, event.clientY, mode);
});
canvas.addEventListener('pointermove', event => {
const delta = drag.move(event.clientX, event.clientY);
if (delta === null || camera === undefined) {
return;
}
event.preventDefault();
userMovedCamera = true;
if (delta.mode === 'rotate') {
camera.rotate(delta.dx, delta.dy);
} else {
camera.move(delta.dx, delta.dy);
}
redraw();
});
for (const type of ['pointerup', 'pointercancel', 'lostpointercapture']) {
canvas.addEventListener(type, () => drag.end());
}
// The canvas' CSS size comes from the layout only (see style.css), so
// setting its pixel size here does not trigger another resize.
const resizeObserver = new ResizeObserver(entries => {
const size = canvasPixelSize(entries[entries.length - 1], window.devicePixelRatio || 1);
width = size.width;
height = size.height;
cssHeight = size.cssHeight;
canvas.width = width;
canvas.height = height;
if (camera !== undefined) {
camera.reshape(width, height, cssHeight);
}
redraw();
});
resizeObserver.observe(canvas);
store.subscribe((state, previous) => {
let removed = false;
for (const [layerId, scene] of scenes) {
if (!state.layers.some(layer => layer.id === layerId)) {
deleteScene(gl, scene);
scenes.delete(layerId);
removed = true;
}
}
if (scenes.size === 0 && state.layers !== previous.layers && removed) {
camera = undefined;
userMovedCamera = false;
}
if (removed || state.hidden !== previous.hidden || state.selection !== previous.selection) {
requestRedraw();
}
});
return {
available: true,
// Uploads a block of layerId; places the camera on the first block.
// Blocks of layers that are no longer in the store (a replaced load) are ignored.
addBlock(layerId, block, getSummary) {
if (!store.get().layers.some(layer => layer.id === layerId)) {
return;
}
let scene = scenes.get(layerId);
if (scene === undefined) {
scene = createScene();
scenes.set(layerId, scene);
}
updateBlock(gl, scene, block);
if (camera === undefined) {
centerCamera(getSummary());
}
scheduleLoadingRedraw();
},
// Centres the camera on the whole layer unless the user moved it meanwhile.
finishLayer(layerId, summary) {
if (!scenes.has(layerId) || summary.vertexCount === 0) {
return;
}
if (camera === undefined || !userMovedCamera) {
centerCamera(summary);
}
redraw();
},
requestRedraw,
// For debugging and automated browser checks.
get camera() {
return camera;
},
};
}
function showWebGL2Missing(container, canvas) {
const message = document.createElement('div');
message.className = 'viewer-message';
message.textContent = 'This viewer needs WebGL 2, which is not available in this browser. ';
const link = document.createElement('a');
link.href = 'https://get.webgl.org/webgl2/';
link.textContent = 'Check WebGL 2 support';
message.append(link);
canvas.replaceWith(message);
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment