Commit 6360fdfb authored by Matthias Betz's avatar Matthias Betz
Browse files

fix drag handling, missing WebGL 2, normal fallback and HiDPI canvas



- pointer events with pointer capture: a drag only starts on the canvas,
  continues outside it and ends on release; deltas from clientX/Y instead
  of the non-standard layerX (a drag entering from outside made the
  camera NaN)
- without WebGL 2 show a message in the page instead of an alert with raw
  HTML, and refuse to load files
- normal fallback for zero-area polygons used vec3.create(args), which
  ignores its arguments and returned a zero normal
- canvas resolution in device pixels (CSS size * devicePixelRatio), mouse
  movement stays in CSS pixels
- index.html: charset, viewport and lang

Co-Authored-By: default avatarClaude Opus 5.5 (1M context) <noreply@anthropic.com>
parent d7e5ee79
Pipeline #12496 passed with stages
in 13 seconds
......@@ -538,9 +538,10 @@
// no valid normal vector found
if (ring.length < 3) {
// no three points, return x-axis
return vec3.create([1, 0, 0]);
return vec3.fromValues(1, 0, 0);
}
return calculateNormalWithCross(vec3.create(ring[0]), vec3.create(ring[1]), vec3.create(ring[2]));
// vec3.create() takes no arguments; clone copies the points.
return calculateNormalWithCross(vec3.clone(ring[0]), vec3.clone(ring[1]), vec3.clone(ring[2]));
}
var v = vec3.fromValues(coords[0], coords[1], coords[2]);
vec3.normalize(v, v);
......
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<title>CityGML Viewer</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CityGML Viewer</title>
<script type="text/javascript" src="sax.js"></script>
<script type="text/javascript" src="libtess.min.js"></script>
<script type="text/javascript" src="gl-matrix-min.js"></script>
......
......@@ -73,6 +73,36 @@
return 2 * distance * Math.tan(FIELD_OF_VIEW / 2) / viewportHeight;
}
// Tracks a mouse drag that started on the canvas. move() returns the
// movement since the last position, or null when no drag is active, so a
// drag entering the canvas from outside is ignored.
function createDragTracker() {
var active = false;
var mode = null;
var lastX = 0;
var lastY = 0;
return {
start: function (x, y, dragMode) {
active = true;
mode = dragMode;
lastX = x;
lastY = y;
},
move: function (x, y) {
if (!active) {
return null;
}
var delta = { mode: mode, dx: x - lastX, dy: y - lastY };
lastX = x;
lastY = y;
return delta;
},
end: function () {
active = false;
}
};
}
return {
MIN_ZOOM_STEP: MIN_ZOOM_STEP,
MIN_DISTANCE: MIN_DISTANCE,
......@@ -80,6 +110,7 @@
zoomDistance: zoomDistance,
clipPlanes: clipPlanes,
fitDistance: fitDistance,
panPerPixel: panPerPixel
panPerPixel: panPerPixel,
createDragTracker: createDragTracker
};
});
......@@ -104,7 +104,29 @@
gl.bindVertexArray(null);
}
// Canvas size from a ResizeObserver entry: width/height in device pixels
// for a sharp image on high-resolution screens, cssWidth/cssHeight in CSS
// pixels as used by mouse events.
function canvasPixelSize(entry, devicePixelRatio) {
var cssWidth, cssHeight;
if (entry.contentBoxSize) {
var box = entry.contentBoxSize[0] || entry.contentBoxSize;
cssWidth = box.inlineSize;
cssHeight = box.blockSize;
} else {
cssWidth = entry.contentRect.width;
cssHeight = entry.contentRect.height;
}
return {
width: Math.round(cssWidth * devicePixelRatio),
height: Math.round(cssHeight * devicePixelRatio),
cssWidth: cssWidth,
cssHeight: cssHeight
};
}
return {
canvasPixelSize: canvasPixelSize,
createProgram: createProgram,
createScene: createScene,
updateBlock: updateBlock,
......
......@@ -12,18 +12,31 @@ var hiddenLods = new Set();
var clearColor = new Float32Array([0.9411765, 1.0, 1.0, 1.0]);
var clearDepth = new Float32Array([1.0]);
var camera;
// Canvas size in device pixels (drawing) and its height in CSS pixels (mouse).
var width = 0;
var height = 0;
var cssHeight = 0;
var skipFirstResizeEvent = false;
var mouseX;
var mouseY;
var drag = Navigation.createDragTracker();
var canvas = document.getElementById("viewport");
var progress = document.getElementById("progress");
var gl = canvas.getContext('webgl2', { antialias: true });
var isWebGL2 = !!gl;
if (!isWebGL2) {
alert('WebGL 2 is not available. See https://www.khronos.org/webgl/wiki/Getting_a_WebGL_Implementation">How to get a WebGL 2 implementation');
if (!gl) {
showWebGL2Missing();
}
// Replaces the canvas with a message; nothing else touches WebGL then,
// because loading a file is refused and the camera is never created.
function showWebGL2Missing() {
var message = document.createElement("div");
message.className = "alert alert-danger m-3";
message.textContent = "This viewer needs WebGL 2, which is not available in this browser. ";
var link = document.createElement("a");
link.href = "https://get.webgl.org/webgl2/";
link.textContent = "Check WebGL 2 support";
message.appendChild(link);
canvas.replaceWith(message);
}
canvas.oncontextmenu = function () {
......@@ -40,39 +53,39 @@ canvas.onwheel = function (event) {
redraw(gl);
};
canvas.onmousedown = function (event) {
// 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.onpointerdown = function (event) {
if (camera === undefined) {
return;
}
event.preventDefault();
if (event.buttons === 1 || event.buttons === 2) {
mouseX = event.layerX;
mouseY = event.layerY;
var 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.onmousemove = function (event) {
if (camera === undefined) {
canvas.onpointermove = function (event) {
var delta = drag.move(event.clientX, event.clientY);
if (delta === null || camera === undefined) {
return;
}
event.preventDefault();
if (event.buttons === 1) {
var deltaX = event.layerX - mouseX;
var deltaY = event.layerY - mouseY;
mouseX = event.layerX;
mouseY = event.layerY;
userMovedCamera = true;
camera.rotate(deltaX, deltaY);
redraw(gl);
} else if (event.buttons === 2) {
var deltaX = event.layerX - mouseX;
var deltaY = event.layerY - mouseY;
mouseX = event.layerX;
mouseY = event.layerY;
userMovedCamera = true;
camera.move(deltaX, deltaY);
redraw(gl);
userMovedCamera = true;
if (delta.mode === "rotate") {
camera.rotate(delta.dx, delta.dy);
} else {
camera.move(delta.dx, delta.dy);
}
redraw(gl);
};
canvas.onpointerup = canvas.onpointercancel = canvas.onlostpointercapture = function () {
drag.end();
};
const resizeObserver = new ResizeObserver(entries => {
......@@ -81,18 +94,10 @@ const resizeObserver = new ResizeObserver(entries => {
return;
}
for (let entry of entries) {
if (entry.contentBoxSize) {
if (entry.contentBoxSize[0]) {
height = entry.contentBoxSize[0].blockSize;
width = entry.contentBoxSize[0].inlineSize;
} else {
height = entry.contentBoxSize.blockSize;
width = entry.contentBoxSize.inlineSize;
}
} else {
width = entry.contentRect.width;
height = entry.contentRect.height;
}
var size = Renderer.canvasPixelSize(entry, window.devicePixelRatio || 1);
width = size.width;
height = size.height;
cssHeight = size.cssHeight;
}
resizeObserver.unobserve(canvas);
skipFirstResizeEvent = true;
......@@ -103,9 +108,10 @@ const resizeObserver = new ResizeObserver(entries => {
return;
}
gl.viewport(0, 0, width, height);
camera.reshape(width, height);
camera.reshape(width, height, cssHeight);
redraw(gl);
});
resizeObserver.observe(canvas);
// Shows or hides one LOD (0-4, or null for geometry outside any lodN element).
......@@ -138,7 +144,7 @@ function initGl() {
// distance from which the whole bounding box fits into the view.
function centerCamera(summary) {
camera = new Camera(program, gl);
camera.reshape(width, height);
camera.reshape(width, height, cssHeight);
camera.lookAt(summary.center);
camera.setDistance(-Navigation.fitDistance(summary.bbox.getDiagonalLength(), width / height));
camera.rotate(Math.PI / 2 * 500, 300);
......@@ -198,9 +204,11 @@ class Camera {
this.viewportHeight = 1;
}
reshape(width, height) {
// 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 = height;
this.viewportHeight = cssHeight;
this.updateMatrix();
}
......@@ -308,6 +316,9 @@ function getShaderSource(id) {
var input = document.getElementById('input');
input.addEventListener("change", function () {
if (!gl) {
return;
}
if (this.files && this.files[0]) {
loadFile(this.files[0]);
}
......
......@@ -200,6 +200,13 @@ const UP_LIGHT_BROWN = '123 105 82 255';
const UP_BRIDGE = '149 74 47 255';
const UP_WHITE = '149 149 149 255';
test('a polygon whose area cancels out is shaded by the orientation of its first corner', () => {
// Bow-tie: Newell's method gives a zero normal; the fallback uses the first
// three points, (1,1,0) x (1,0,0) = (0,0,-1): shade 0.615 -> 157 for white.
const result = parse(cityModel(polygon('0 0 0 1 1 0 1 0 0 0 1 0 0 0 0')));
assert.deepEqual(distinctColors(result), ['157 157 157 255']);
});
test('land use is light brown', () => {
const result = parse(cityModel(`<luse:LandUse>${lodMultiSurface(1, polygon(squarePosList(0, 0, 0, 1)))}</luse:LandUse>`));
assert.deepEqual(distinctColors(result), [UP_LIGHT_BROWN]);
......
......@@ -129,3 +129,27 @@ test('panning close in is slow but not zero', () => {
const perPixel = Navigation.panPerPixel(Navigation.MIN_DISTANCE, 600);
assert.ok(perPixel > 0 && perPixel < 0.05, `${perPixel}`);
});
// --- mouse dragging --------------------------------------------------------
test('moving the mouse without a press on the canvas does not move the camera', () => {
// A drag that starts outside the canvas used to turn the camera into NaN.
const drag = Navigation.createDragTracker();
assert.equal(drag.move(120, 80), null);
});
test('a drag reports the movement since the last position', () => {
const drag = Navigation.createDragTracker();
drag.start(100, 100, 'rotate');
assert.deepEqual(drag.move(110, 95), { mode: 'rotate', dx: 10, dy: -5 });
assert.deepEqual(drag.move(111, 95), { mode: 'rotate', dx: 1, dy: 0 });
});
test('after the drag ends, movement is ignored until the next press', () => {
const drag = Navigation.createDragTracker();
drag.start(100, 100, 'pan');
drag.end();
assert.equal(drag.move(300, 300), null);
drag.start(300, 300, 'pan');
assert.deepEqual(drag.move(305, 300), { mode: 'pan', dx: 5, dy: 0 });
});
......@@ -115,3 +115,20 @@ test('deleteScene frees every GPU object the scene created', () => {
assert.ok(gl.deleted.has(obj), `${obj.kind} ${obj.id} not deleted`);
}
});
// --- canvas resolution ---------------------------------------------------
test('the canvas resolution is the CSS size scaled by the device pixel ratio', () => {
const entry = { contentBoxSize: [{ inlineSize: 600, blockSize: 300 }] };
assert.deepEqual(renderer.canvasPixelSize(entry, 1.5), { width: 900, height: 450, cssWidth: 600, cssHeight: 300 });
});
test('fractional CSS sizes are rounded to whole device pixels', () => {
const entry = { contentBoxSize: [{ inlineSize: 600.5, blockSize: 300.25 }] };
assert.deepEqual(renderer.canvasPixelSize(entry, 2), { width: 1201, height: 601, cssWidth: 600.5, cssHeight: 300.25 });
});
test('old browsers without contentBoxSize use contentRect', () => {
const entry = { contentRect: { width: 640, height: 320 } };
assert.deepEqual(renderer.canvasPixelSize(entry, 1), { width: 640, height: 320, cssWidth: 640, cssHeight: 320 });
});
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