Commit 38166861 authored by Matthias Betz's avatar Matthias Betz
Browse files

new page layout with status, file input, LOD panel and module entry



Co-Authored-By: default avatarClaude Opus 5.5 (1M context) <noreply@anthropic.com>
parent 5ba8de0d
...@@ -17,6 +17,28 @@ CityGML with a coordinate system not in meters will not work. ...@@ -17,6 +17,28 @@ CityGML with a coordinate system not in meters will not work.
- right click + drag: move the view - right click + drag: move the view
- mouse wheel: zoom in or out - mouse wheel: zoom in or out
## Development
The viewer is plain JavaScript modules without a build step; `public/` is
deployed as it is. Browsers do not load modules from `file://`, so serve the
folder over HTTP, e.g.:
```
cd public
python -m http.server
```
Structure of `public/src/`:
- `main.js` – connects all parts
- `app/` – store and state (layers, hidden filter values, selection)
- `loading/` – CityGML parser, zip reader, file loading
- `viewer/` – WebGL rendering, camera, navigation
- `ui/` – file input, status, LOD panel
Third-party libraries are in `public/vendor/` (`proj4.js` is kept for
coordinate system support but not loaded yet).
## Tests ## Tests
Requires Node.js 22 or newer: Requires Node.js 22 or newer:
......
...@@ -5,76 +5,33 @@ ...@@ -5,76 +5,33 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>CityGML Viewer</title> <title>CityGML Viewer</title>
<script type="text/javascript" src="sax.js"></script> <link rel="stylesheet" href="src/style.css">
<script type="text/javascript" src="libtess.min.js"></script> <script src="vendor/sax.js"></script>
<script type="text/javascript" src="gl-matrix-min.js"></script> <script src="vendor/libtess.min.js"></script>
<script type="text/javascript" src="proj4.js"></script> <script src="vendor/gl-matrix-min.js"></script>
<script type="text/javascript" src="citygml.js"></script> <!-- vendor/proj4.js is kept for coordinate system support, not loaded yet -->
<script type="text/javascript" src="renderer.js"></script> <script type="module" src="src/main.js"></script>
<script type="text/javascript" src="zipreader.js"></script> <script>
<script type="text/javascript" src="navigation.js"></script> // Browsers do not load JavaScript modules from file:// URLs.
<script id="vs" type="x-shader/x-vertex"> if (location.protocol === 'file:') {
#version 300 es document.addEventListener('DOMContentLoaded', function () {
precision mediump float; document.getElementById('viewer').textContent =
'This page has to be served over HTTP because it uses JavaScript modules, ' +
in vec3 position; 'for example with "python -m http.server" in the public folder.';
in vec3 color; });
uniform mat4 projViewModel;
out vec3 interpolatedColor;
void main() {
gl_Position = projViewModel * vec4(position, 1);
interpolatedColor = color;
} }
</script> </script>
<script id="fs" type="x-shader/x-fragment">
#version 300 es
precision mediump float;
in vec3 interpolatedColor;
out vec4 outputColor;
void main() {
outputColor = vec4(interpolatedColor, 1);
}
</script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<style>
canvas {
width: 100%;
height: 90%;
padding-left: 0;
padding-right: 0;
margin-top: 20px;
margin-left: auto;
margin-right: auto;
display: block;
}
</style>
</head> </head>
<body> <body>
<div class="container"> <header class="app-header">
<div class="row"> <div id="file"></div>
<div class="col mt-2"> <div id="status"></div>
<div class="input-group"> </header>
<label class="input-group-text" for="input">Open CityGML or ZIP file for viewing </label> <aside class="app-sidebar">
<input class="form-control" id="input" type="file" /> <section id="lod-panel" class="panel"></section>
</div> </aside>
</div> <main id="viewer" class="app-viewer"></main>
</div>
<div id="progressDiv" class="row" style="display: none;">
<div class="col">
<div class="progress" >
<div id="progress" style="transition: none;" class="progress-bar progress-bar-striped" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
</div>
<canvas id="viewport"></canvas>
<script type="text/javascript" src="run_sax.js"></script>
</body> </body>
</html> </html>
window.mat4 = glMatrix.mat4
window.vec2 = glMatrix.vec2
window.vec3 = glMatrix.vec3
var scene;
var program;
var userMovedCamera = false;
var redrawPending = false;
var loadController;
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 drag = Navigation.createDragTracker();
var canvas = document.getElementById("viewport");
var progress = document.getElementById("progress");
var gl = canvas.getContext('webgl2', { antialias: true });
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 () {
return false;
};
canvas.onwheel = function (event) {
if (camera === undefined) {
return;
}
event.preventDefault();
userMovedCamera = true;
camera.scroll(event.deltaY, event.deltaMode);
redraw(gl);
};
// 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;
}
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.onpointermove = function (event) {
var 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(gl);
};
canvas.onpointerup = canvas.onpointercancel = canvas.onlostpointercapture = function () {
drag.end();
};
const resizeObserver = new ResizeObserver(entries => {
if (skipFirstResizeEvent) {
skipFirstResizeEvent = false;
return;
}
for (let entry of entries) {
var size = Renderer.canvasPixelSize(entry, window.devicePixelRatio || 1);
width = size.width;
height = size.height;
cssHeight = size.cssHeight;
}
resizeObserver.unobserve(canvas);
skipFirstResizeEvent = true;
canvas.height = height;
canvas.width = width;
resizeObserver.observe(canvas);
if (camera === undefined) {
return;
}
gl.viewport(0, 0, 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).
function setLodVisible(lod, visible) {
if (visible) {
hiddenLods.delete(lod);
} else {
hiddenLods.add(lod);
}
if (camera !== undefined) {
redraw(gl);
}
}
function isLodVisible(lod) {
return !hiddenLods.has(lod);
}
function initGl() {
if (program === undefined) {
program = Renderer.createProgram(gl, getShaderSource('vs'), getShaderSource('fs'));
}
gl.enable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
gl.viewport(0, 0, width, height);
gl.useProgram(program);
}
// Places the camera on summary.center (see CityGML.createParser) 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(-Navigation.fitDistance(summary.bbox.getDiagonalLength(), width / height));
camera.rotate(Math.PI / 2 * 500, 300);
}
// 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;
var lastLoadingRedraw = 0;
var lastRedrawDuration = 0;
function scheduleRedraw() {
if (redrawPending) {
return;
}
redrawPending = true;
var interval = Math.max(LOADING_REDRAW_MIN_INTERVAL_MS, LOADING_REDRAW_SHARE * lastRedrawDuration);
var delay = Math.max(0, lastLoadingRedraw + interval - performance.now());
setTimeout(function () {
requestAnimationFrame(function () {
redrawPending = false;
if (camera !== undefined) {
var start = performance.now();
redraw(gl);
lastRedrawDuration = performance.now() - start;
lastLoadingRedraw = performance.now();
}
});
}, delay);
}
function redraw(gl) {
gl.clearBufferfv(gl.COLOR, 0, clearColor);
gl.clearBufferfv(gl.DEPTH, 0, clearDepth);
Renderer.drawScene(gl, scene, isLodVisible);
}
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 Navigation.clipPlanes), so it
// is rebuilt with every view change.
updateMatrix() {
var planes = Navigation.clipPlanes(Math.abs(this.distance));
mat4.perspective(this.projMatrix, Navigation.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) {
var rotationX = -dragDiffX * 1.0 / 500;
var rotationZ = -dragDiffY * 1.0 / 500;
var tempRotationValue = this.rotateAroundZ + rotationZ;
if (tempRotationValue < -Math.PI / 2 + 0.0001 || tempRotationValue > Math.PI / 2 - 0.0001) {
// to close to 90 degree, stop rotation
rotationZ = 0;
}
this.rotateAroundZ += rotationZ;
var res = vec3.create();
vec3.sub(res, this.eyepos, this.centerPos);
vec3.rotateZ(res, res, this.origin, rotationX);
var 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) {
var hangle = angle * 0.5;
var sinAngle = Math.sin(hangle);
var qx = aX * sinAngle, qy = aY * sinAngle, qz = aZ * sinAngle;
var qw = Math.cos(hangle);
var w2 = qw * qw, x2 = qx * qx, y2 = qy * qy, z2 = qz * qz, zw = qz * qw;
var xy = qx * qy, xz = qx * qz, yw = qy * qw, yz = qy * qz, xw = qx * qw;
var 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) {
var 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;
var 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. this.distance is negative
// (the eye is behind the centre), Navigation works with positive distances.
scroll(delta, deltaMode) {
this.setDistance(-Navigation.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 Navigation.panPerPixel).
move(dragDiffX, dragDiffY) {
var perPixel = Navigation.panPerPixel(Math.abs(this.distance), this.viewportHeight);
var res = vec3.create();
vec3.sub(res, this.centerPos, this.eyepos);
var dir = vec2.fromValues(res[0], res[1]);
vec2.normalize(dir, dir);
var yDrag = vec2.create();
vec2.scale(yDrag, dir, dragDiffY * perPixel);
// handle diffY
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));
// handle diffX
var 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();
}
}
function getShaderSource(id) {
var content = document.getElementById(id).textContent;
return content.replace(/^\s+|\s+$/g, '');
}
var input = document.getElementById('input');
input.addEventListener("change", function () {
if (!gl) {
return;
}
if (this.files && this.files[0]) {
loadFile(this.files[0]);
}
});
// Zip archives start with a local file header ("PK\3\4").
function isZip(file) {
return file.slice(0, 4).arrayBuffer().then(function (buffer) {
return buffer.byteLength === 4 && new DataView(buffer).getUint32(0, true) === 0x04034b50;
});
}
// Loads a CityGML file, or the CityGML file inside a zip archive (see
// ZipReader.findCityGmlEntry), and shows its geometry while it is being read: the
// camera is placed when the first block arrives and, unless the user has
// moved it by then, centred again on the whole model at the end.
function loadFile(file) {
if (loadController !== undefined) {
// stop a load that is still running
loadController.abort();
}
var controller = new AbortController();
loadController = controller;
if (scene !== undefined) {
Renderer.deleteScene(gl, scene);
}
scene = Renderer.createScene();
camera = undefined;
userMovedCamera = false;
initGl();
gl.clearBufferfv(gl.COLOR, 0, clearColor);
var parser = CityGML.createParser({
onBlock: function (block) {
Renderer.updateBlock(gl, scene, block);
if (camera === undefined) {
centerCamera(parser.summary());
}
scheduleRedraw();
}
});
setProgress(0);
document.getElementById("progressDiv").style.removeProperty("display");
var sourceName = file.name;
isZip(file)
.then(function (zip) {
if (!zip) {
return CityGML.readFile(file, parser, { signal: controller.signal, onProgress: setProgress });
}
// Only the zip's directory and the selected entry are read, decompressed as a stream.
return ZipReader.listEntries(file)
.then(function (entries) {
var entry = ZipReader.findCityGmlEntry(entries);
sourceName = file.name + "/" + entry.name;
return ZipReader.openEntry(file, entry, { onProgress: setProgress });
})
.then(function (stream) {
return CityGML.readStream(stream, parser, { signal: controller.signal });
});
})
.then(function (summary) {
if (summary.vertexCount === 0) {
alert("No polygons found in " + sourceName);
return;
}
if (camera === undefined || !userMovedCamera) {
centerCamera(summary);
}
redraw(gl);
})
.catch(function (error) {
if (error.name === "AbortError") {
return;
}
console.error(error);
alert("Could not display file: " + error.message);
})
.finally(function () {
if (loadController === controller) {
document.getElementById("progressDiv").style.display = "none";
}
});
}
function setProgress(progressValue) {
if (progressValue > 1) {
progressValue = 1;
}
progressValue = Math.floor(progressValue * 100);
var fn = function() {
progress.setAttribute("style", "width: " + Number(progressValue) + "%");
progress.ariaValueNow = progressValue;
progress.innerHTML = progressValue + "%";
}
setTimeout(fn);
}
// Composition root: the only module that knows all parts and connects them.
import { createStore } from './app/store.js';
import { addLayer, failLayer, finishLayer, initialState, reportLod, reportProgress } from './app/state.js';
import { loadFile } from './loading/loadFile.js';
import { createViewer } from './viewer/viewer.js';
import { mountFileInput } from './ui/fileInput.js';
import { mountLodPanel } from './ui/lodPanel.js';
import { mountStatus } from './ui/status.js';
const store = createStore(initialState());
const viewer = createViewer(document.getElementById('viewer'), store);
mountStatus(document.getElementById('status'), store);
mountLodPanel(document.getElementById('lod-panel'), store);
mountFileInput(document.getElementById('file'), { onFile: open, enabled: viewer.available });
let nextLayerId = 1;
let currentLoad = null;
// Loads a file as a new layer; a load that is still running is stopped.
function open(file) {
if (currentLoad !== null) {
currentLoad.abort();
}
const controller = new AbortController();
currentLoad = controller;
const layerId = nextLayerId++;
store.update(addLayer, { id: layerId, name: file.name });
loadFile(file, {
signal: controller.signal,
onBlock: (block, getSummary) => viewer.addBlock(layerId, block, getSummary),
onLod: lod => store.update(reportLod, layerId, lod),
onProgress: fraction => store.update(reportProgress, layerId, fraction),
}).then(({ summary }) => {
store.update(finishLayer, layerId, summary.vertexCount);
viewer.finishLayer(layerId, summary);
}, error => {
if (error.name === 'AbortError') {
return;
}
console.error(error);
store.update(failLayer, layerId, error.message);
});
}
// For debugging and automated browser checks.
globalThis.cityGmlViewer = { store, viewer, open };
/* Layout only; no visual design yet. */
html, body {
height: 100%;
margin: 0;
}
body {
display: grid;
grid-template-rows: auto 1fr;
grid-template-columns: 14rem 1fr;
grid-template-areas:
"header header"
"sidebar viewer";
font-family: system-ui, sans-serif;
}
.app-header {
grid-area: header;
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
padding: 0.5rem;
border-bottom: 1px solid #ccc;
}
#status {
display: flex;
gap: 0.5rem;
align-items: center;
}
.app-sidebar {
grid-area: sidebar;
overflow: auto;
padding: 0.5rem;
border-right: 1px solid #ccc;
}
.panel h2 {
font-size: 1rem;
margin: 0 0 0.5rem;
}
.panel label {
display: block;
}
/* The canvas is positioned absolutely, so its pixel size never feeds back
into the layout. */
.app-viewer {
grid-area: viewer;
position: relative;
min-width: 0;
min-height: 0;
}
.viewer-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
touch-action: none;
}
.viewer-message {
padding: 1rem;
}
// File selection. The input is reset after each selection so that choosing
// the same file again reloads it (the browser fires no change event for an
// unchanged selection).
export function mountFileInput(container, { onFile, enabled = true }) {
const label = document.createElement('label');
label.textContent = 'Open CityGML or ZIP file ';
const input = document.createElement('input');
input.type = 'file';
input.id = 'file-input';
input.disabled = !enabled;
input.addEventListener('change', () => {
const file = input.files && input.files[0];
if (file) {
onFile(file);
}
input.value = '';
});
label.append(input);
container.append(label);
return input;
}
// Progress bar and status message of the loaded layers.
function layerText(layer) {
switch (layer.status) {
case 'loading':
return `Loading ${layer.name}: ${Math.floor(layer.progress * 100)} %`;
case 'error':
return `Could not display ${layer.name}: ${layer.error}`;
default:
return layer.vertexCount === 0
? `No polygons found in ${layer.name}`
: `${layer.name}: ${layer.vertexCount.toLocaleString('en')} vertices`;
}
}
export function statusText(state) {
return state.layers.map(layerText).join('; ');
}
export function mountStatus(container, store) {
const bar = document.createElement('progress');
bar.max = 1;
bar.hidden = true;
const text = document.createElement('span');
container.append(bar, text);
function render(state) {
const loading = state.layers.find(layer => layer.status === 'loading');
bar.hidden = loading === undefined;
if (loading !== undefined) {
bar.value = loading.progress;
}
text.textContent = statusText(state);
}
render(store.get());
return store.subscribe(render);
}
import test from 'node:test';
import assert from 'node:assert/strict';
import { statusText } from '../public/src/ui/status.js';
import { initialState, addLayer, reportProgress, finishLayer, failLayer } from '../public/src/app/state.js';
const loading = () => addLayer(initialState(), { id: 1, name: 'city.gml' });
test('nothing loaded gives an empty status', () => {
assert.equal(statusText(initialState()), '');
});
test('a loading layer shows its name and progress in whole percent', () => {
assert.equal(statusText(reportProgress(loading(), 1, 0.426)), 'Loading city.gml: 42 %');
});
test('a ready layer shows its vertex count', () => {
assert.equal(statusText(finishLayer(loading(), 1, 19122537)), 'city.gml: 19,122,537 vertices');
});
test('a ready layer without vertices says that no polygons were found', () => {
assert.equal(statusText(finishLayer(loading(), 1, 0)), 'No polygons found in city.gml');
});
test('a failed layer shows the error message', () => {
assert.equal(statusText(failLayer(loading(), 1, 'The file is a 7z archive.')),
'Could not display city.gml: The file is a 7z archive.');
});
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