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

stream large CityGML and zip files, fix geometry and view bugs



Loading
- parse into fixed-size per-LOD blocks and upload them to the GPU while
  reading, so memory stays flat for multi-GB files (3.4 GB 3D-DLM loads
  with a JS heap below 90 MB) and the model is shown during loading
- load the single .gml (or .xml) inside a zip archive, decompressed as a
  stream, including ZIP64
- reject 7z/RAR/gzip and other non-XML input at once with a clear message,
  and enable the sax buffer limit that unterminated "<!" in binary data
  used to bypass until the tab ran out of memory
- decode UTF-8 across chunk borders, cancel a running load when a new
  file is chosen, report load errors

Geometry
- split coordinates on any whitespace (indented posList, one pos per line)
- subtract the origin in double precision before float32 conversion
- handle PolygonPatch, Rectangle and Triangle
- tag geometry with its LOD; setLodVisible() prepares LOD switches
- light brown for land use and relief, nested colours restore the parent

View
- bind shader attribute locations, check compile/link errors, free GPU
  buffers of the previous file
- infinite far plane with a near plane following the camera distance,
  starting distance fitted to the model without a 10 km cap
- minimum zoom step and distance, pan speed proportional to the distance

Tests: npm test (Node 22+), 73 tests

Co-Authored-By: default avatarClaude Opus 5.5 (1M context) <noreply@anthropic.com>
parent 53ae9548
......@@ -4,6 +4,11 @@
Simply load a CityGML for the viewer to show the geometry.
A ZIP archive can be loaded directly if it contains exactly one `.gml` file
(or, without any `.gml` file, exactly one `.xml` file). The file is
decompressed while it is read, so large archives (also ZIP64) work.
Encrypted entries and Deflate64 compression are not supported.
CityGML with a coordinate system not in meters will not work.
## Controls
......@@ -11,3 +16,11 @@ CityGML with a coordinate system not in meters will not work.
- Left click + drag: Rotate the view
- right click + drag: move the view
- mouse wheel: zoom in or out
## Tests
Requires Node.js 22 or newer:
```
npm test
```
{
"name": "citygml-viewer",
"private": true,
"scripts": {
"test": "node --test \"tests/*.test.js\""
}
}
// CityGML parsing and mesh building, independent of the DOM and WebGL.
// Works as a browser global (window.CityGML) and as a CommonJS module for tests.
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory(require('./sax.js'), require('./libtess.min.js'), require('./gl-matrix-min.js'));
} else {
root.CityGML = factory(root.sax, root.libtess, root.glMatrix);
}
})(this, function (sax, libtess, glMatrix) {
'use strict';
var vec3 = glMatrix.vec3;
var axis = vec3.fromValues(19, 0.8, 1.5);
vec3.normalize(axis, axis);
var options = {
trim: false,
normalize: false,
xmlns: false,
// sax only enforces its buffer limit (MAX_BUFFER_LENGTH) when it tracks
// the position; without it, an unterminated "<!" or comment in a broken
// or binary file is buffered until the tab runs out of memory.
position: true,
strictEntities: true
};
class BBox {
constructor() {
this.lowerCorner = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
this.upperCorner = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
}
expandX(x) {
if (this.lowerCorner[0] > x) {
this.lowerCorner[0] = x;
}
if (this.upperCorner[0] < x) {
this.upperCorner[0] = x;
}
}
expandY(y) {
if (this.lowerCorner[1] > y) {
this.lowerCorner[1] = y;
}
if (this.upperCorner[1] < y) {
this.upperCorner[1] = y;
}
}
expandZ(z) {
if (this.lowerCorner[2] > z) {
this.lowerCorner[2] = z;
}
if (this.upperCorner[2] < z) {
this.upperCorner[2] = z;
}
}
getDiagonalLength() {
var xDif = this.upperCorner[0] - this.lowerCorner[0];
var yDif = this.upperCorner[1] - this.lowerCorner[1];
var zDif = this.upperCorner[2] - this.lowerCorner[2];
return Math.sqrt(xDif * xDif + yDif * yDif + zDif * zDif);
}
getCenter() {
var x = (this.upperCorner[0] + this.lowerCorner[0]) / 2.0;
var y = (this.upperCorner[1] + this.lowerCorner[1]) / 2.0;
var z = (this.upperCorner[2] + this.lowerCorner[2]) / 2.0;
return [x, y, z];
}
}
var DEFAULT_COLOR = [1.0, 1.0, 1.0];
function getColorForTagName(tagName) {
switch (tagName) {
case "GROUNDSURFACE":
return [0.9411765, 0.9019608, 0.54901963];
case "ROOFSURFACE":
return [1.0, 0.0, 0.0];
case "DOOR":
return [1.0, 0.784313, 0.0];
case "WINDOW":
return [0.0, 0.5019608, 0.5019608];
case "WATERBODY":
return [0.5294118, 0.80784315, 0.98039216];
case "BRIDGE":
case "BRIDGEPART":
return [1, 0.49803922, 0.3137255];
case "PLANTCOVER":
case "SOLITARYVEGETATIONOBJECT":
return [0.5647059, 0.93333334, 0.5647059];
case "INTERSECTION":
case "ROAD":
case "RAILWAY":
case "SECTION":
case "SQUARE":
case "TRACK":
case "TRANSPORTATIONCOMPLEX":
case "WATERWAY":
return [0.4, 0.4, 0.4];
case "LANDUSE":
case "RELIEFFEATURE":
case "TINRELIEF":
// light brown (tan, #D2B48C)
return [210 / 255, 180 / 255, 140 / 255];
}
return undefined;
}
// Surface elements whose rings (exterior + interiors) form one planar polygon.
var POLYGON_TAGS = new Set(["POLYGON", "POLYGONPATCH", "RECTANGLE", "TRIANGLE"]);
// lod0FootPrint, lod2MultiSurface, lod3Solid, ... (tag names are upper case
// because sax runs in non-strict mode)
var LOD_TAG = /^LOD([0-4])[A-Z]/;
// Default block size in triangles; a block holds 12 B position + 4 B color
// per vertex, so 131072 triangles are about 6 MB.
var DEFAULT_BLOCK_TRIANGLES = 131072;
// Every n-th vertex is kept as a candidate for the camera centre.
var DEFAULT_SAMPLE_STRIDE = 64;
// Returns a streaming parser that writes triangles straight into fixed-size
// blocks instead of keeping polygons around, so that memory stays bounded
// for files of several GB.
//
// A block is { id, lod, capacity, count, positions: Float32Array(capacity*3),
// colors: Uint8Array(capacity*4) } holding triangles of one LOD (0..4, or
// null outside any lodN element). Positions are relative to the first
// coordinate in the file (summary.origin), subtracted in double precision.
//
// opts.onBlock(block) is called synchronously whenever a block got new
// vertices: when it becomes full (after that the parser drops it) and on
// flush(). The same block object is passed repeatedly while it fills, so the
// receiver must consume the new vertices [previous count, count) right away.
function createParser(opts) {
opts = opts || {};
var onBlock = opts.onBlock || function () { };
var blockVertices = 3 * (opts.blockTriangles || DEFAULT_BLOCK_TRIANGLES);
var sampleStride = opts.sampleStride || DEFAULT_SAMPLE_STRIDE;
var parser = sax.parser(false, options);
// Colours of the open coloured elements, innermost last; closing one
// restores the colour of the enclosing element (white if there is none).
var colorStack = [];
var color = DEFAULT_COLOR;
var lod = null;
var readingRing = false;
var currentPolygon = [];
var coordinateString = "";
var bbox = new BBox();
var origin = null;
var openBlocks = new Map(); // lod -> block being filled
var changedBlocks = new Set();
var nextBlockId = 1;
var vertexCount = 0;
var sum = [0, 0, 0];
var samples = new Float32Array(3 * 1024);
var sampleCount = 0;
parser.ontext = function (t) {
if (readingRing) {
coordinateString += t;
}
};
parser.onopentag = function (node) {
var tagName = node.name.substring(node.name.indexOf(':') + 1);
var lodMatch = LOD_TAG.exec(tagName);
var tempColor = getColorForTagName(tagName);
if (tempColor !== undefined) {
colorStack.push(tempColor);
color = tempColor;
} else if (lodMatch) {
lod = Number(lodMatch[1]);
} else if (POLYGON_TAGS.has(tagName)) {
currentPolygon = [];
} else if (tagName === "LINEARRING") {
readingRing = true;
coordinateString = "";
}
};
parser.onclosetag = function (node) {
var tagName = node.substring(node.indexOf(':') + 1);
if (tagName === "LINEARRING") {
var ring = parseRing(coordinateString);
if (ring.length > 0) {
currentPolygon.push(ring);
}
readingRing = false;
coordinateString = "";
} else if (tagName === "POS") {
coordinateString += " ";
} else if (POLYGON_TAGS.has(tagName)) {
if (currentPolygon.length > 0) {
addPolygon(currentPolygon);
}
currentPolygon = [];
} else if (LOD_TAG.test(tagName)) {
lod = null;
} else if (getColorForTagName(tagName) !== undefined) {
colorStack.pop();
color = colorStack.length > 0 ? colorStack[colorStack.length - 1] : DEFAULT_COLOR;
}
};
// Parses whitespace separated x y z triples into [[x, y, z], ...],
// relative to the origin.
function parseRing(coordinateString) {
var trimmed = coordinateString.trim();
if (trimmed === "") {
return [];
}
var split = trimmed.split(/\s+/);
var coords = [];
for (var i = 0; i + 2 < split.length; i = i + 3) {
var x = parseFloat(split[i]);
var y = parseFloat(split[i + 1]);
var z = parseFloat(split[i + 2]);
if (origin === null) {
origin = [x, y, z];
}
bbox.expandX(x);
bbox.expandY(y);
bbox.expandZ(z);
coords.push([x - origin[0], y - origin[1], z - origin[2]]);
}
return coords;
}
function addPolygon(rings) {
var triangles = triangulate(rings);
var cos = vec3.dot(triangles.normal, axis);
var shade = Math.acos(cos) / Math.PI * 0.6 + 0.3;
var r = Math.round(color[0] * shade * 255);
var g = Math.round(color[1] * shade * 255);
var b = Math.round(color[2] * shade * 255);
var verts = triangles.vertices;
for (var i = 0; i < verts.length; i += 3) {
addVertex(verts[i], verts[i + 1], verts[i + 2], r, g, b);
}
}
function addVertex(x, y, z, r, g, b) {
var block = openBlocks.get(lod);
if (block === undefined) {
block = {
id: nextBlockId++,
lod: lod,
capacity: blockVertices,
count: 0,
positions: new Float32Array(blockVertices * 3),
colors: new Uint8Array(blockVertices * 4),
};
openBlocks.set(lod, block);
}
var p = block.count * 3;
block.positions[p] = x;
block.positions[p + 1] = y;
block.positions[p + 2] = z;
var c = block.count * 4;
block.colors[c] = r;
block.colors[c + 1] = g;
block.colors[c + 2] = b;
block.colors[c + 3] = 255;
block.count++;
sum[0] += x;
sum[1] += y;
sum[2] += z;
if (vertexCount % sampleStride === 0) {
addSample(x, y, z);
}
vertexCount++;
// Capacity is a multiple of 3, so a full block always ends on a whole triangle.
if (block.count === block.capacity) {
openBlocks.delete(lod);
changedBlocks.delete(block);
onBlock(block);
} else {
changedBlocks.add(block);
}
}
function addSample(x, y, z) {
if ((sampleCount + 1) * 3 > samples.length) {
var grown = new Float32Array(samples.length * 2);
grown.set(samples);
samples = grown;
}
samples[sampleCount * 3] = x;
samples[sampleCount * 3 + 1] = y;
samples[sampleCount * 3 + 2] = z;
sampleCount++;
}
// The sampled vertex closest to the centroid, relative to the origin.
function center() {
if (vertexCount === 0) {
return [0, 0, 0];
}
var cx = sum[0] / vertexCount, cy = sum[1] / vertexCount, cz = sum[2] / vertexCount;
var best = 0;
var minDistSq = Infinity;
for (var i = 0; i < sampleCount; i++) {
var dx = samples[i * 3] - cx;
var dy = samples[i * 3 + 1] - cy;
var dz = samples[i * 3 + 2] - cz;
var distSq = dx * dx + dy * dy + dz * dz;
if (distSq < minDistSq) {
minDistSq = distSq;
best = i;
}
}
return [samples[best * 3], samples[best * 3 + 1], samples[best * 3 + 2]];
}
// Hands every block with new vertices to onBlock.
function flush() {
var changed = Array.from(changedBlocks);
changedBlocks.clear();
for (var i = 0; i < changed.length; i++) {
onBlock(changed[i]);
}
}
// { origin, center, bbox, vertexCount } of everything parsed so far.
function summary() {
return {
origin: origin === null ? [0, 0, 0] : origin,
center: center(),
bbox: bbox,
vertexCount: vertexCount
};
}
return {
write: function (text) {
parser.write(text);
},
flush: flush,
summary: summary,
close: function () {
parser.close();
flush();
return summary();
}
};
}
var DEFAULT_CHUNK_SIZE = 512 * 1024;
// Gives timers, input and rendering a turn. Needed when reading a stream
// whose data is always ready (e.g. decompression), as promise callbacks
// alone never let the event loop run. Not setTimeout(0): browsers clamp
// repeated timeouts to 4 ms, which would add up over thousands of flushes.
// Not scheduler.yield(): its continuation runs before queued timers, so
// setTimeout callbacks (progress bar, redraws) would still starve.
function yieldToEventLoop() {
return new Promise(function (resolve) {
var channel = new MessageChannel();
channel.port1.onmessage = function () {
channel.port1.close();
resolve();
};
channel.port2.postMessage(null);
});
}
// Signatures of formats people try to open instead of (zipped) CityGML.
var ARCHIVE_SIGNATURES = [
{ name: "7z archive", bytes: [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C] },
{ name: "RAR archive", bytes: [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07] },
{ name: "xz file", bytes: [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00] },
{ name: "gzip file", bytes: [0x1F, 0x8B] },
{ name: "bzip2 file", bytes: [0x42, 0x5A, 0x68] },
{ name: "zip archive", bytes: [0x50, 0x4B, 0x03, 0x04] },
];
function startsWith(bytes, signature) {
if (bytes.length < signature.length) {
return false;
}
for (var i = 0; i < signature.length; i++) {
if (bytes[i] !== signature[i]) {
return false;
}
}
return true;
}
// Checks the first bytes of the input before anything is parsed, so that
// binary files fail at once with a clear message instead of being parsed
// as XML. Returns true once the input looks like XML, false if the bytes
// seen so far are only whitespace.
function checkLooksLikeXml(bytes) {
for (var s = 0; s < ARCHIVE_SIGNATURES.length; s++) {
if (startsWith(bytes, ARCHIVE_SIGNATURES[s].bytes)) {
throw new Error("The file is a " + ARCHIVE_SIGNATURES[s].name + ", which cannot be read here. " +
"Please unpack it and open the CityGML file, or pack it as a .zip archive.");
}
}
var i = startsWith(bytes, [0xEF, 0xBB, 0xBF]) ? 3 : 0;
while (i < bytes.length && (bytes[i] === 0x20 || bytes[i] === 0x09 || bytes[i] === 0x0A || bytes[i] === 0x0D)) {
i++;
}
if (i === bytes.length) {
return false;
}
if (bytes[i] !== 0x3C) {
throw new Error("The file is not an XML file (it does not start with '<'), so it cannot be CityGML.");
}
return true;
}
// Feeds a ReadableStream of UTF-8 bytes into parser. The parser is flushed
// whenever opts.flushBytes (default 512 KB) were read, so that geometry can
// be shown while loading. opts.onChunk(byteLength) is called after each
// chunk. Resolves with parser.close(); rejects with an AbortError once
// opts.signal aborts.
function readStream(stream, parser, opts) {
opts = opts || {};
var flushBytes = opts.flushBytes || DEFAULT_CHUNK_SIZE;
var onChunk = opts.onChunk || function () { };
var signal = opts.signal;
var reader = stream.getReader();
// Decoding in streaming mode keeps multi-byte characters split between chunks intact.
var decoder = new TextDecoder();
var unflushed = 0;
var looksLikeXml = false;
function next() {
if (signal && signal.aborted) {
return reader.cancel().then(function () {
signal.throwIfAborted();
});
}
return reader.read().then(function (result) {
if (result.done) {
parser.write(decoder.decode());
return parser.close();
}
if (!looksLikeXml) {
looksLikeXml = checkLooksLikeXml(result.value);
}
parser.write(decoder.decode(result.value, { stream: true }));
unflushed += result.value.byteLength;
onChunk(result.value.byteLength);
if (unflushed >= flushBytes) {
parser.flush();
unflushed = 0;
return yieldToEventLoop().then(next);
}
return next();
});
}
return Promise.resolve().then(next);
}
// Reads a Blob/File in slices of opts.chunkSize into parser, see readStream.
// opts.onProgress(fraction) reports the share of the file read.
function readFile(file, parser, opts) {
opts = opts || {};
var chunkSize = opts.chunkSize || DEFAULT_CHUNK_SIZE;
var onProgress = opts.onProgress || function () { };
var offset = 0;
var consumed = 0;
var slices = new ReadableStream({
pull: function (controller) {
if (offset >= file.size) {
controller.close();
return;
}
var slice = file.slice(offset, offset + chunkSize);
offset += chunkSize;
return slice.arrayBuffer().then(function (buffer) {
controller.enqueue(new Uint8Array(buffer));
});
}
});
onProgress(0);
return readStream(slices, parser, {
signal: opts.signal,
flushBytes: chunkSize,
onChunk: function (byteLength) {
consumed += byteLength;
onProgress(file.size === 0 ? 1 : consumed / file.size);
}
});
}
function triangulate(polygon) {
var triangleVerts = [];
tessy.gluTessBeginPolygon(triangleVerts);
var exterior = polygon[0];
var normal = calculateNormal(polygon[0]);
tessy.gluTessNormal(normal[0], normal[1], normal[2]);
tessy.gluTessBeginContour();
for (var j = 0; j < exterior.length; j++) {
tessy.gluTessVertex(exterior[j], exterior[j]);
}
tessy.gluTessEndContour();
for (var i = 1; i < polygon.length; i++) {
tessy.gluTessBeginContour();
var contour = polygon[i];
for (var k = 0; k < contour.length; k++) {
tessy.gluTessVertex(contour[k], contour[k]);
}
tessy.gluTessEndContour();
}
tessy.gluTessEndPolygon();
return {
vertices: triangleVerts,
normal: normal
};
}
function calculateNormal(ring) {
var coords = [0, 0, 0];
for (var i = 0; i < ring.length - 1; i++) {
var current = ring[i + 0];
var next = ring[i + 1];
coords[0] += (current[2] + next[2]) * (current[1] - next[1]);
coords[1] += (current[0] + next[0]) * (current[2] - next[2]);
coords[2] += (current[1] + next[1]) * (current[0] - next[0]);
}
if (coords[0] == 0 && coords[1] == 0 && coords[2] == 0) {
// no valid normal vector found
if (ring.length < 3) {
// no three points, return x-axis
return vec3.create([1, 0, 0]);
}
return calculateNormalWithCross(vec3.create(ring[0]), vec3.create(ring[1]), vec3.create(ring[2]));
}
var v = vec3.fromValues(coords[0], coords[1], coords[2]);
vec3.normalize(v, v);
return v;
}
function calculateNormalWithCross(v1, v2, v3) {
var dir1 = vec3.create();
vec3.sub(dir1, v2, v1);
var dir2 = vec3.create();
vec3.sub(dir2, v3, v1);
var cross = vec3.create();
vec3.cross(cross, dir1, dir2);
vec3.normalize(cross, cross);
return cross;
}
var tessy = (function initTesselator() {
// function called for each vertex of tesselator output
function vertexCallback(data, polyVertArray) {
polyVertArray[polyVertArray.length] = data[0];
polyVertArray[polyVertArray.length] = data[1];
polyVertArray[polyVertArray.length] = data[2];
}
function begincallback(type) {
}
function errorcallback(errno) {
}
// callback for when segments intersect and must be split
function combinecallback(coords, data, weight) {
return [coords[0], coords[1], coords[2]];
}
function edgeCallback(flag) {
// don't really care about the flag, but need no-strip/no-fan behavior
}
var tessy = new libtess.GluTesselator();
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_VERTEX_DATA, vertexCallback);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_BEGIN, begincallback);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_ERROR, errorcallback);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_COMBINE, combinecallback);
tessy.gluTessCallback(libtess.gluEnum.GLU_TESS_EDGE_FLAG, edgeCallback);
return tessy;
})();
return {
BBox: BBox,
createParser: createParser,
readStream: readStream,
readFile: readFile
};
});
......@@ -7,6 +7,10 @@
<script type="text/javascript" src="libtess.min.js"></script>
<script type="text/javascript" src="gl-matrix-min.js"></script>
<script type="text/javascript" src="proj4.js"></script>
<script type="text/javascript" src="citygml.js"></script>
<script type="text/javascript" src="renderer.js"></script>
<script type="text/javascript" src="zipreader.js"></script>
<script type="text/javascript" src="navigation.js"></script>
<script id="vs" type="x-shader/x-vertex">
#version 300 es
precision mediump float;
......@@ -53,7 +57,7 @@
<div class="row">
<div class="col mt-2">
<div class="input-group">
<label class="input-group-text" for="input">Open CityGML file for viewing </label>
<label class="input-group-text" for="input">Open CityGML or ZIP file for viewing </label>
<input class="form-control" id="input" type="file" />
</div>
</div>
......
// Camera navigation rules that do not depend on the DOM or WebGL.
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.Navigation = factory();
}
})(this, function () {
'use strict';
// Share of the distance one pixel of wheel movement zooms: 10 % per notch.
var ZOOM_PER_PIXEL = 0.001;
// At least this many metres per notch, so that zooming does not stall
// when the camera is close to the centre.
var MIN_ZOOM_STEP = 2;
// Closest distance to the centre in metres; going further would pass it
// and flip the view. The near clipping plane is at 1 m.
var MIN_DISTANCE = 2;
// Wheel deltas in pixels; one notch is about 100 px (3 lines in Firefox).
var PIXELS_PER_NOTCH = 100;
var PIXELS_PER_LINE = PIXELS_PER_NOTCH / 3;
var PIXELS_PER_PAGE = 800;
function wheelPixels(delta, deltaMode) {
if (deltaMode === 1) {
return delta * PIXELS_PER_LINE;
}
if (deltaMode === 2) {
return delta * PIXELS_PER_PAGE;
}
return delta;
}
// New camera distance (positive, in metres) after a wheel event.
// Positive delta zooms out, negative zooms in.
function zoomDistance(distance, delta, deltaMode) {
var pixels = wheelPixels(delta, deltaMode);
var step = Math.max(distance * ZOOM_PER_PIXEL * Math.abs(pixels),
MIN_ZOOM_STEP * Math.abs(pixels) / PIXELS_PER_NOTCH);
var next = pixels < 0 ? distance - step : distance + step;
return Math.max(MIN_DISTANCE, next);
}
// Vertical field of view of the camera.
var FIELD_OF_VIEW = Math.PI / 2;
// The near plane follows the camera distance: depth precision depends on
// it, so a fixed near plane makes distant coplanar layers (terrain, land
// use, ground surfaces) flicker, while too large a value cuts off geometry
// in front of the camera.
var NEAR_PER_DISTANCE = 1 / 200;
var MIN_NEAR = 0.01;
// Clip planes for a camera at distance metres from its target. The far
// plane is infinite, so everything in front of the camera is drawn.
function clipPlanes(distance) {
return { near: Math.max(MIN_NEAR, distance * NEAR_PER_DISTANCE), far: Infinity };
}
// Distance at which a model with the given bounding box diagonal fits into
// the view: its bounding sphere touches the narrower field of view.
function fitDistance(diagonal, aspect) {
var halfVertical = FIELD_OF_VIEW / 2;
var halfHorizontal = Math.atan(Math.tan(halfVertical) * aspect);
var halfAngle = Math.min(halfVertical, halfHorizontal);
return Math.max(MIN_DISTANCE, (diagonal / 2) / Math.sin(halfAngle));
}
// Metres the camera moves sideways per pixel of mouse drag, so that the
// point at the target stays under the mouse: the view is
// 2 * distance * tan(fov / 2) high at the target.
function panPerPixel(distance, viewportHeight) {
return 2 * distance * Math.tan(FIELD_OF_VIEW / 2) / viewportHeight;
}
return {
MIN_ZOOM_STEP: MIN_ZOOM_STEP,
MIN_DISTANCE: MIN_DISTANCE,
FIELD_OF_VIEW: FIELD_OF_VIEW,
zoomDistance: zoomDistance,
clipPlanes: clipPlanes,
fitDistance: fitDistance,
panPerPixel: panPerPixel
};
});
// WebGL2 helpers for the CityGML viewer: shader program, mesh upload, drawing.
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.Renderer = factory();
}
})(this, function () {
'use strict';
// Vertex attribute locations, bound explicitly so they do not depend on the driver.
var POSITION_LOCATION = 0;
var COLOR_LOCATION = 1;
function createShader(gl, source, type) {
var shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
var log = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error("Shader compilation failed: " + log);
}
return shader;
}
function createProgram(gl, vertexShaderSource, fragmentShaderSource) {
var program = gl.createProgram();
var vshader = createShader(gl, vertexShaderSource, gl.VERTEX_SHADER);
var fshader = createShader(gl, fragmentShaderSource, gl.FRAGMENT_SHADER);
gl.attachShader(program, vshader);
gl.deleteShader(vshader);
gl.attachShader(program, fshader);
gl.deleteShader(fshader);
gl.bindAttribLocation(program, POSITION_LOCATION, "position");
gl.bindAttribLocation(program, COLOR_LOCATION, "color");
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
var log = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
throw new Error("Shader program linking failed: " + log);
}
return program;
}
// A scene holds one GPU block (VAO + position and color buffer) per parser
// block, keyed by block id.
function createScene() {
return { blocks: new Map() };
}
// Uploads the vertices a parser block (see CityGML.createParser) gained
// since the last call. The GPU buffers are allocated once at the block's
// full capacity, so a growing block is only appended to.
function updateBlock(gl, scene, block) {
var gpu = scene.blocks.get(block.id);
if (gpu === undefined) {
gpu = { lod: block.lod, uploaded: 0 };
gpu.vao = gl.createVertexArray();
gl.bindVertexArray(gpu.vao);
gpu.posVbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, gpu.posVbo);
gl.bufferData(gl.ARRAY_BUFFER, block.positions.byteLength, gl.STATIC_DRAW);
gl.enableVertexAttribArray(POSITION_LOCATION);
gl.vertexAttribPointer(POSITION_LOCATION, 3, gl.FLOAT, false, 0, 0);
// RGBA bytes per vertex; the shader only reads RGB.
gpu.colorVbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, gpu.colorVbo);
gl.bufferData(gl.ARRAY_BUFFER, block.colors.byteLength, gl.STATIC_DRAW);
gl.enableVertexAttribArray(COLOR_LOCATION);
gl.vertexAttribPointer(COLOR_LOCATION, 3, gl.UNSIGNED_BYTE, true, 4, 0);
gl.bindVertexArray(null);
scene.blocks.set(block.id, gpu);
}
if (block.count > gpu.uploaded) {
gl.bindBuffer(gl.ARRAY_BUFFER, gpu.posVbo);
gl.bufferSubData(gl.ARRAY_BUFFER, gpu.uploaded * 12, block.positions.subarray(gpu.uploaded * 3, block.count * 3));
gl.bindBuffer(gl.ARRAY_BUFFER, gpu.colorVbo);
gl.bufferSubData(gl.ARRAY_BUFFER, gpu.uploaded * 4, block.colors.subarray(gpu.uploaded * 4, block.count * 4));
gpu.uploaded = block.count;
}
}
function deleteScene(gl, scene) {
for (const gpu of scene.blocks.values()) {
gl.deleteVertexArray(gpu.vao);
gl.deleteBuffer(gpu.posVbo);
gl.deleteBuffer(gpu.colorVbo);
}
scene.blocks.clear();
}
// Draws the uploaded part of every block whose LOD isLodVisible(lod) accepts.
function drawScene(gl, scene, isLodVisible) {
for (const gpu of scene.blocks.values()) {
if (gpu.uploaded > 0 && isLodVisible(gpu.lod)) {
gl.bindVertexArray(gpu.vao);
gl.drawArrays(gl.TRIANGLES, 0, gpu.uploaded);
}
}
gl.bindVertexArray(null);
}
return {
createProgram: createProgram,
createScene: createScene,
updateBlock: updateBlock,
deleteScene: deleteScene,
drawScene: drawScene
};
});
This diff is collapsed.
// Streaming access to entries of a zip archive (Blob/File) without loading the
// archive into memory: only the central directory is read up front, an entry
// is decompressed on the fly with the browser's DecompressionStream.
// Supports stored and deflated entries and ZIP64 (archives and entries > 4 GB).
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.ZipReader = factory();
}
})(this, function () {
'use strict';
var EOCD_SIGNATURE = 0x06054b50;
var ZIP64_LOCATOR_SIGNATURE = 0x07064b50;
var ZIP64_EOCD_SIGNATURE = 0x06064b50;
var CENTRAL_SIGNATURE = 0x02014b50;
var LOCAL_SIGNATURE = 0x04034b50;
var EOCD_SIZE = 22;
var MAX_COMMENT = 0xFFFF;
var METHOD_NAMES = { 1: "Shrink", 6: "Implode", 9: "Deflate64", 12: "BZIP2", 14: "LZMA", 93: "Zstandard", 95: "XZ" };
function readBytes(blob, start, end) {
return blob.slice(start, end).arrayBuffer().then(function (buffer) {
return new DataView(buffer);
});
}
function uint64(view, pos) {
return view.getUint32(pos, true) + view.getUint32(pos + 4, true) * 0x100000000;
}
// Lists the entries as { name, directory, method, flags, compressedSize,
// uncompressedSize, localHeaderOffset }.
function listEntries(blob) {
var tailStart = Math.max(0, blob.size - EOCD_SIZE - MAX_COMMENT);
return readBytes(blob, tailStart, blob.size).then(function (tail) {
var eocd = -1;
for (var i = tail.byteLength - EOCD_SIZE; i >= 0; i--) {
if (tail.getUint32(i, true) === EOCD_SIGNATURE) {
eocd = i;
break;
}
}
if (eocd < 0) {
throw new Error("Not a zip archive (no end of central directory found)");
}
var count = tail.getUint16(eocd + 10, true);
var cdSize = tail.getUint32(eocd + 12, true);
var cdOffset = tail.getUint32(eocd + 16, true);
if (count !== 0xFFFF && cdSize !== 0xFFFFFFFF && cdOffset !== 0xFFFFFFFF) {
return readCentralDirectory(blob, cdOffset, cdSize, count);
}
// ZIP64: the locator directly precedes the end of central directory record.
var locator = eocd - 20;
if (locator < 0 || tail.getUint32(locator, true) !== ZIP64_LOCATOR_SIGNATURE) {
throw new Error("Broken ZIP64 archive (no ZIP64 locator found)");
}
var recordOffset = uint64(tail, locator + 8);
return readBytes(blob, recordOffset, recordOffset + 56).then(function (rec) {
if (rec.getUint32(0, true) !== ZIP64_EOCD_SIGNATURE) {
throw new Error("Broken ZIP64 archive (no ZIP64 end of central directory)");
}
return readCentralDirectory(blob, uint64(rec, 48), uint64(rec, 40), uint64(rec, 32));
});
});
}
function readCentralDirectory(blob, offset, size, count) {
return readBytes(blob, offset, offset + size).then(function (cd) {
var decoder = new TextDecoder();
var entries = [];
var pos = 0;
for (var n = 0; n < count; n++) {
if (cd.getUint32(pos, true) !== CENTRAL_SIGNATURE) {
throw new Error("Broken zip archive (bad central directory entry " + n + ")");
}
var entry = {
flags: cd.getUint16(pos + 8, true),
method: cd.getUint16(pos + 10, true),
compressedSize: cd.getUint32(pos + 20, true),
uncompressedSize: cd.getUint32(pos + 24, true),
localHeaderOffset: cd.getUint32(pos + 42, true),
};
var nameLength = cd.getUint16(pos + 28, true);
var extraLength = cd.getUint16(pos + 30, true);
var commentLength = cd.getUint16(pos + 32, true);
entry.name = decoder.decode(new Uint8Array(cd.buffer, cd.byteOffset + pos + 46, nameLength));
entry.directory = entry.name.endsWith("/");
applyZip64Extra(cd, pos + 46 + nameLength, extraLength, entry);
entries.push(entry);
pos += 46 + nameLength + extraLength + commentLength;
}
return entries;
});
}
// The ZIP64 extra field holds 64-bit values for exactly those fields that
// are 0xFFFFFFFF in the fixed header, in this order.
function applyZip64Extra(view, start, length, entry) {
var pos = start;
while (pos + 4 <= start + length) {
var id = view.getUint16(pos, true);
var size = view.getUint16(pos + 2, true);
if (id === 0x0001) {
var p = pos + 4;
if (entry.uncompressedSize === 0xFFFFFFFF) { entry.uncompressedSize = uint64(view, p); p += 8; }
if (entry.compressedSize === 0xFFFFFFFF) { entry.compressedSize = uint64(view, p); p += 8; }
if (entry.localHeaderOffset === 0xFFFFFFFF) { entry.localHeaderOffset = uint64(view, p); }
}
pos += 4 + size;
}
}
// Resolves with a ReadableStream of the entry's uncompressed bytes.
// opts.onProgress(fraction) reports the share of compressed bytes read.
function openEntry(blob, entry, opts) {
opts = opts || {};
var onProgress = opts.onProgress || function () { };
return Promise.resolve().then(function () {
if (entry.flags & 0x1) {
throw new Error(entry.name + " is encrypted; encrypted zip entries are not supported");
}
if (entry.method !== 0 && entry.method !== 8) {
var methodName = METHOD_NAMES[entry.method] || "method " + entry.method;
throw new Error(entry.name + " is compressed with " + methodName + ", which the browser cannot decompress; " +
"please re-pack the archive with standard Deflate compression");
}
return readBytes(blob, entry.localHeaderOffset, entry.localHeaderOffset + 30);
}).then(function (local) {
if (local.getUint32(0, true) !== LOCAL_SIGNATURE) {
throw new Error("Broken zip archive (bad local header for " + entry.name + ")");
}
// The local header has its own name and extra field lengths.
var dataStart = entry.localHeaderOffset + 30 + local.getUint16(26, true) + local.getUint16(28, true);
var total = entry.compressedSize;
var consumed = 0;
var counting = new TransformStream({
transform: function (chunk, controller) {
consumed += chunk.byteLength;
onProgress(total === 0 ? 1 : consumed / total);
controller.enqueue(chunk);
}
});
var stream = blob.slice(dataStart, dataStart + total).stream().pipeThrough(counting);
return entry.method === 8 ? stream.pipeThrough(new DecompressionStream("deflate-raw")) : stream;
});
}
function isIgnored(entry) {
return entry.directory || entry.name.startsWith("__MACOSX/");
}
function hasExtension(entry, extension) {
return entry.name.toLowerCase().endsWith(extension);
}
// The entry to load: the only .gml file, or, if there is no .gml file, the
// only .xml file. Throws if there is none or more than one candidate.
function findCityGmlEntry(entries) {
var files = entries.filter(function (e) { return !isIgnored(e); });
var gml = files.filter(function (e) { return hasExtension(e, ".gml"); });
var candidates = gml.length > 0 ? gml : files.filter(function (e) { return hasExtension(e, ".xml"); });
if (candidates.length === 1) {
return candidates[0];
}
if (gml.length > 1) {
throw new Error("The archive contains " + gml.length + " .gml files; only archives with one are supported:\n" +
gml.map(function (e) { return e.name; }).join("\n"));
}
throw new Error("The archive contains no CityGML file (.gml, or a single .xml)" +
(candidates.length > 1 ? "; found several .xml files:\n" + candidates.map(function (e) { return e.name; }).join("\n") : ""));
}
return {
listEntries: listEntries,
openEntry: openEntry,
findCityGmlEntry: findCityGmlEntry
};
});
const test = require('node:test');
const assert = require('node:assert/strict');
const CityGML = require('../public/citygml.js');
function cityModel(body) {
return `<?xml version="1.0" encoding="UTF-8"?>
<core:CityModel xmlns:core="http://www.opengis.net/citygml/2.0" xmlns:bldg="http://www.opengis.net/citygml/building/2.0" xmlns:gml="http://www.opengis.net/gml">
${body}
</core:CityModel>`;
}
// Closed square ring in the plane z, lower-left corner (x, y), side length s.
function squarePosList(x, y, z, s) {
return [
[x, y, z], [x + s, y, z], [x + s, y + s, z], [x, y + s, z], [x, y, z],
].map(p => p.join(' ')).join(' ');
}
function polygon(posList) {
return `<gml:Polygon><gml:exterior><gml:LinearRing><gml:posList>${posList}</gml:posList></gml:LinearRing></gml:exterior></gml:Polygon>`;
}
function triangle(posList) {
return `<gml:Triangle><gml:exterior><gml:LinearRing><gml:posList>${posList}</gml:posList></gml:LinearRing></gml:exterior></gml:Triangle>`;
}
function lodMultiSurface(lod, content) {
return `<bldg:lod${lod}MultiSurface><gml:MultiSurface><gml:surfaceMember>${content}</gml:surfaceMember></gml:MultiSurface></bldg:lod${lod}MultiSurface>`;
}
// Parses xml in one write. Blocks are filled in place, so the last delivered
// state of each block (by id) is its final content.
function parse(xml, opts = {}) {
const blocks = new Map();
const p = CityGML.createParser({ ...opts, onBlock: b => blocks.set(b.id, b) });
p.write(xml);
const summary = p.close();
return { summary, blocks: [...blocks.values()] };
}
// Absolute vertex coordinates (origin + local position) of all blocks,
// or of the blocks of one LOD.
function vertices(result, lod) {
const out = [];
for (const b of result.blocks) {
if (lod !== undefined && b.lod !== lod) continue;
for (let v = 0; v < b.count; v++) {
out.push([0, 1, 2].map(k => result.summary.origin[k] + b.positions[v * 3 + k]));
}
}
return out;
}
// Distinct vertices as sorted "x y z" strings.
function distinct(verts) {
return [...new Set(verts.map(v => v.join(' ')))].sort();
}
const UNIT_SQUARE = ['0 0 0', '0 1 0', '1 0 0', '1 1 0'];
// --- coordinate whitespace ----------------------------------------------
test('posList spread over indented lines gives the listed vertices', () => {
const result = parse(cityModel(`
<gml:Polygon>
<gml:exterior>
<gml:LinearRing>
<gml:posList>
0 0 0
1 0 0
1 1 0
0 1 0
0 0 0
</gml:posList>
</gml:LinearRing>
</gml:exterior>
</gml:Polygon>`));
assert.equal(result.summary.vertexCount, 6, 'a square is two triangles');
assert.deepEqual(distinct(vertices(result)), UNIT_SQUARE);
});
test('one gml:pos per line gives the listed vertices', () => {
const result = parse(cityModel(`
<gml:Polygon>
<gml:exterior>
<gml:LinearRing>
<gml:pos>0 0 0</gml:pos>
<gml:pos>1 0 0</gml:pos>
<gml:pos>1 1 0</gml:pos>
<gml:pos>0 1 0</gml:pos>
<gml:pos>0 0 0</gml:pos>
</gml:LinearRing>
</gml:exterior>
</gml:Polygon>`));
assert.deepEqual(distinct(vertices(result)), UNIT_SQUARE);
});
test('tabs and repeated spaces between coordinates are accepted', () => {
const result = parse(cityModel(polygon('0\t0 0\t1 0 0 1\t1 0 0 1 0\t\t0 0 0')));
assert.deepEqual(distinct(vertices(result)), UNIT_SQUARE);
});
test('bounding box ignores whitespace between coordinates', () => {
const result = parse(cityModel(polygon(`
10 20 30
11 20 30
11 21 30
10 20 30`)));
assert.deepEqual(result.summary.bbox.lowerCorner, [10, 20, 30]);
assert.deepEqual(result.summary.bbox.upperCorner, [11, 21, 30]);
});
test('bounding box diagonal is the full length of its diagonal', () => {
// x 10..13, y 20..24, z 30 -> sqrt(3^2 + 4^2) = 5
const result = parse(cityModel(polygon('10 20 30 13 20 30 13 24 30 10 20 30')));
assert.equal(result.summary.bbox.getDiagonalLength(), 5);
});
// --- precision of large projected coordinates -------------------------
// Gauss-Krueger coordinates near Stuttgart; float32 spacing there is 0.25-0.5 m.
const GK_X = 3513000.37;
const GK_Y = 5403456.71;
test('local positions keep centimetre detail at Gauss-Krueger coordinates', () => {
const result = parse(cityModel(polygon(squarePosList(GK_X, GK_Y, 250, 0.3))));
const b = result.blocks[0];
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (let v = 0; v < b.count; v++) {
minX = Math.min(minX, b.positions[v * 3]); maxX = Math.max(maxX, b.positions[v * 3]);
minY = Math.min(minY, b.positions[v * 3 + 1]); maxY = Math.max(maxY, b.positions[v * 3 + 1]);
}
assert.ok(Math.abs((maxX - minX) - 0.3) < 1e-3, `x extent ${maxX - minX}, expected 0.3`);
assert.ok(Math.abs((maxY - minY) - 0.3) < 1e-3, `y extent ${maxY - minY}, expected 0.3`);
});
test('origin plus local position gives back the original coordinate', () => {
const result = parse(cityModel(polygon(squarePosList(GK_X, GK_Y, 250, 0.3))));
const corners = [
[GK_X, GK_Y], [GK_X + 0.3, GK_Y], [GK_X + 0.3, GK_Y + 0.3], [GK_X, GK_Y + 0.3],
];
const verts = vertices(result);
assert.equal(verts.length, 6);
for (const [x, y, z] of verts) {
const match = corners.some(c => Math.abs(c[0] - x) < 1e-3 && Math.abs(c[1] - y) < 1e-3);
assert.ok(match, `vertex (${x}, ${y}) is not a corner of the square`);
assert.ok(Math.abs(z - 250) < 1e-3);
}
});
// --- camera centre -------------------------------------------------------
test('center is the vertex closest to the centroid, relative to the origin', () => {
// Centroid of the nine vertices is (10.33, 0.33, 0); (10, 0, 0) is closest.
const tri = (x) => triangle(`${GK_X + x} ${GK_Y} 250 ${GK_X + x + 1} ${GK_Y} 250 ${GK_X + x} ${GK_Y + 1} 250 ${GK_X + x} ${GK_Y} 250`);
const result = parse(cityModel(`<gml:TriangulatedSurface><gml:trianglePatches>
${tri(0)}${tri(10)}${tri(20)}</gml:trianglePatches></gml:TriangulatedSurface>`), { sampleStride: 1 });
const absolute = [0, 1, 2].map(k => result.summary.origin[k] + result.summary.center[k]);
assert.ok(Math.abs(absolute[0] - (GK_X + 10)) < 1e-3, `center x ${absolute[0]}`);
assert.ok(Math.abs(absolute[1] - GK_Y) < 1e-3, `center y ${absolute[1]}`);
assert.ok(Math.abs(absolute[2] - 250) < 1e-3, `center z ${absolute[2]}`);
});
test('a file without geometry gives no blocks and a centre at the origin', () => {
const result = parse(cityModel(''));
assert.deepEqual(result.blocks, []);
assert.equal(result.summary.vertexCount, 0);
assert.deepEqual(Array.from(result.summary.center), [0, 0, 0]);
});
// --- blocks ----------------------------------------------------------------
test('blocks are filled up to capacity and never split a triangle', () => {
const result = parse(cityModel(polygon(squarePosList(0, 0, 0, 1)) + polygon(squarePosList(5, 0, 0, 1))),
{ blockTriangles: 1 });
assert.deepEqual(result.blocks.map(b => [b.capacity, b.count]), [[3, 3], [3, 3], [3, 3], [3, 3]]);
});
test('vertex colors are shaded RGBA bytes of the surface color', () => {
// Roof red (1, 0, 0), facing up: shade factor acos(n.axis)/pi*0.6+0.3 = 0.585 -> 149.
const result = parse(cityModel(`<bldg:RoofSurface>${lodMultiSurface(2, polygon(squarePosList(0, 0, 0, 1)))}</bldg:RoofSurface>`));
const b = result.blocks[0];
for (let v = 0; v < b.count; v++) {
assert.deepEqual(Array.from(b.colors.subarray(v * 4, v * 4 + 4)), [149, 0, 0, 255]);
}
});
// Colors of all vertices of a parse result as distinct "r g b a" strings.
function distinctColors(result) {
const set = new Set();
for (const b of result.blocks) {
for (let v = 0; v < b.count; v++) set.add(Array.from(b.colors.subarray(v * 4, v * 4 + 4)).join(' '));
}
return [...set];
}
// Upward-facing surfaces have the shade factor 0.585 (see the roof test):
// light brown (210, 180, 140) -> (123, 105, 82), bridge (255, 127, 80) -> (149, 74, 47).
const UP_LIGHT_BROWN = '123 105 82 255';
const UP_BRIDGE = '149 74 47 255';
const UP_WHITE = '149 149 149 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]);
});
test('relief is light brown', () => {
const result = parse(cityModel(`<dem:ReliefFeature><dem:reliefComponent><dem:TINRelief><dem:tin>
<gml:TriangulatedSurface><gml:trianglePatches>${triangle('0 0 0 1 0 0 0 1 0 0 0 0')}</gml:trianglePatches></gml:TriangulatedSurface>
</dem:tin></dem:TINRelief></dem:reliefComponent></dem:ReliefFeature>`));
assert.deepEqual(distinctColors(result), [UP_LIGHT_BROWN]);
});
test('after a nested coloured surface the parent colour applies again', () => {
const result = parse(cityModel(`<brid:Bridge>
<brid:boundedBy><brid:RoofSurface>${lodMultiSurface(2, polygon(squarePosList(0, 0, 10, 1)))}</brid:RoofSurface></brid:boundedBy>
${lodMultiSurface(2, polygon(squarePosList(5, 0, 0, 1)))}
</brid:Bridge>`));
const b = result.blocks[0];
const colorOfVertex = v => Array.from(b.colors.subarray(v * 4, v * 4 + 4)).join(' ');
assert.equal(colorOfVertex(0), '149 0 0 255', 'roof inside the bridge is red');
assert.equal(colorOfVertex(6), UP_BRIDGE, 'bridge surface after the roof has the bridge colour');
});
test('surfaces after a coloured element without a coloured parent are white', () => {
const result = parse(cityModel(`<bldg:Building>
<bldg:boundedBy><bldg:RoofSurface>${lodMultiSurface(2, polygon(squarePosList(0, 0, 10, 1)))}</bldg:RoofSurface></bldg:boundedBy>
<bldg:boundedBy><bldg:WallSurface>${lodMultiSurface(2, polygon(squarePosList(5, 0, 0, 1)))}</bldg:WallSurface></bldg:boundedBy>
</bldg:Building>`));
const b = result.blocks[0];
assert.equal(Array.from(b.colors.subarray(6 * 4, 6 * 4 + 4)).join(' '), UP_WHITE);
});
// --- reading files -------------------------------------------------------
function manySquares(n) {
return cityModel(Array.from({ length: n }, (_, i) => polygon(squarePosList(i * 2, 0, 0, 1))).join('\n'));
}
test('blocks are delivered while the file is still being read', async () => {
let progress = 0;
const progressAtBlock = [];
const parser = CityGML.createParser({ blockTriangles: 2, onBlock: () => progressAtBlock.push(progress) });
await CityGML.readFile(new Blob([manySquares(20)]), parser, { chunkSize: 128, onProgress: p => { progress = p; } });
assert.ok(progressAtBlock.length > 0, 'no blocks delivered');
assert.ok(progressAtBlock[0] < 0.5, `first block only at progress ${progressAtBlock[0]}`);
});
test('reading in chunks delivers every vertex exactly once', async () => {
const blocks = new Map();
const parser = CityGML.createParser({ blockTriangles: 3, onBlock: b => blocks.set(b.id, b) });
const summary = await CityGML.readFile(new Blob([manySquares(20)]), parser, { chunkSize: 100 });
const total = [...blocks.values()].reduce((n, b) => n + b.count, 0);
assert.equal(summary.vertexCount, 20 * 6);
assert.equal(total, 20 * 6);
});
test('loading a second file with a new parser returns only the second file\'s geometry', async () => {
const first = new Blob([cityModel(polygon(squarePosList(0, 0, 0, 1)))]);
const second = new Blob([cityModel(polygon(squarePosList(100, 100, 5, 1)))]);
await CityGML.readFile(first, CityGML.createParser(), { chunkSize: 64 });
const summary = await CityGML.readFile(second, CityGML.createParser(), { chunkSize: 64 });
assert.equal(summary.vertexCount, 6);
assert.deepEqual(summary.bbox.lowerCorner, [100, 100, 5]);
});
const readBytes = bytes => CityGML.readFile(new Blob([new Uint8Array(bytes)]), CityGML.createParser());
test('a 7z archive is rejected right away with a hint to unpack it', async () => {
// 7z signature followed by binary data that contains "<!" (as in the real file).
await assert.rejects(readBytes([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0x00, 0x04, 0x3C, 0x21, 0x9F]),
/7z archive.*unpack/is);
});
test('a gzip file is rejected right away naming the format', async () => {
await assert.rejects(readBytes([0x1F, 0x8B, 0x08, 0x00, 0x3C, 0x21]), /gzip/i);
});
test('a file that does not start with "<" is rejected as not XML', async () => {
await assert.rejects(readBytes([...new TextEncoder().encode('id;name\n1;house\n')]), /not an XML file/i);
});
test('an XML file with UTF-8 byte order mark and leading whitespace is accepted', async () => {
const xml = new TextEncoder().encode('\n ' + cityModel(polygon(squarePosList(0, 0, 0, 1))));
const summary = await readBytes([0xEF, 0xBB, 0xBF, ...xml]);
assert.equal(summary.vertexCount, 6);
});
test('an unterminated declaration fails instead of buffering without limit', async () => {
// Binary data parsed as XML can open a "<!" that never closes; sax must not
// buffer the rest of the file (this crashed the tab for a 250 MB .7z).
const xml = cityModel('<!' + 'x'.repeat(300 * 1024));
await assert.rejects(CityGML.readFile(new Blob([xml]), CityGML.createParser(), { chunkSize: 64 * 1024 }),
/Max buffer length exceeded/);
});
test('long coordinate lists are not limited by the buffer check', async () => {
// 30000 vertices of one ring: far more than 64 KB of text in one posList.
const n = 30000;
const coords = [];
for (let i = 0; i < n; i++) {
const a = 2 * Math.PI * i / n;
coords.push(`${(Math.cos(a) * 100).toFixed(3)} ${(Math.sin(a) * 100).toFixed(3)} 0`);
}
coords.push(coords[0]);
const xml = cityModel(polygon(coords.join(' ')));
assert.ok(xml.length > 5 * 64 * 1024);
const summary = await CityGML.readFile(new Blob([xml]), CityGML.createParser(), { chunkSize: 64 * 1024 });
assert.equal(summary.vertexCount, (n - 2) * 3);
});
test('aborting a read rejects with AbortError and stops reading', async () => {
const controller = new AbortController();
const progressAfterAbort = [];
const reading = CityGML.readFile(new Blob([manySquares(20)]), CityGML.createParser(), {
chunkSize: 64,
signal: controller.signal,
onProgress: p => {
if (controller.signal.aborted) progressAfterAbort.push(p);
else if (p > 0) controller.abort();
},
});
await assert.rejects(reading, { name: 'AbortError' });
assert.deepEqual(progressAfterAbort, []);
});
// --- polygon-like patches end their own ring collection ----------------
const PATCHES = {
PolygonPatch: posList => `<gml:Surface><gml:patches><gml:PolygonPatch><gml:exterior><gml:LinearRing><gml:posList>${posList}</gml:posList></gml:LinearRing></gml:exterior></gml:PolygonPatch></gml:patches></gml:Surface>`,
Rectangle: posList => `<gml:Surface><gml:patches><gml:Rectangle><gml:exterior><gml:LinearRing><gml:posList>${posList}</gml:posList></gml:LinearRing></gml:exterior></gml:Rectangle></gml:patches></gml:Surface>`,
Triangle: posList => `<gml:TriangulatedSurface><gml:trianglePatches>${triangle(posList)}</gml:trianglePatches></gml:TriangulatedSurface>`,
};
for (const [name, patch] of Object.entries(PATCHES)) {
test(`gml:${name} is triangulated on its own and does not leak into the next polygon`, () => {
const ring = name === 'Triangle' ? '0 0 0 1 0 0 0 1 0 0 0 0' : squarePosList(0, 0, 0, 1);
const result = parse(cityModel(
`<bldg:lod1MultiSurface>${patch(ring)}</bldg:lod1MultiSurface>` +
lodMultiSurface(2, polygon(squarePosList(50, 50, 0, 1)))));
assert.deepEqual(distinct(vertices(result, 2)), ['50 50 0', '50 51 0', '51 50 0', '51 51 0']);
assert.equal(vertices(result, 1).length, name === 'Triangle' ? 3 : 6);
});
}
test('triangles of a TriangulatedSurface stay separate triangles', () => {
const result = parse(cityModel(`<gml:TriangulatedSurface><gml:trianglePatches>
${triangle('0 0 0 1 0 0 0 1 0 0 0 0')}
${triangle('1 0 0 1 1 0 0 1 0 1 0 0')}
</gml:trianglePatches></gml:TriangulatedSurface>`));
const verts = vertices(result).map(v => v.join(' '));
assert.equal(verts.length, 6);
assert.deepEqual([...verts.slice(0, 3)].sort(), ['0 0 0', '0 1 0', '1 0 0']);
assert.deepEqual([...verts.slice(3)].sort(), ['0 1 0', '1 0 0', '1 1 0']);
});
// --- LOD tagging ---------------------------------------------------------
const BUILDING_LOD1_LOD2 = cityModel(`
<core:cityObjectMember><bldg:Building>
<bldg:boundedBy><bldg:RoofSurface>${lodMultiSurface(2, polygon(squarePosList(0, 0, 10, 1)))}</bldg:RoofSurface></bldg:boundedBy>
<bldg:lod1Solid><gml:Solid><gml:exterior><gml:CompositeSurface>
<gml:surfaceMember>${polygon(squarePosList(0, 0, 0, 1))}</gml:surfaceMember>
</gml:CompositeSurface></gml:exterior></gml:Solid></bldg:lod1Solid>
<bldg:boundedBy><bldg:WallSurface>${lodMultiSurface(2, polygon(squarePosList(0, 0, 20, 1)))}</bldg:WallSurface></bldg:boundedBy>
</bldg:Building></core:cityObjectMember>`);
test('each block holds geometry of one LOD only', () => {
const result = parse(BUILDING_LOD1_LOD2);
assert.deepEqual(result.blocks.map(b => b.lod).sort(), [1, 2]);
assert.deepEqual(vertices(result, 1).map(v => v[2]), [0, 0, 0, 0, 0, 0]);
const lod2z = vertices(result, 2).map(v => v[2]);
assert.equal(lod2z.length, 12);
assert.ok(lod2z.every(z => z === 10 || z === 20), `LOD2 z values ${lod2z}`);
});
test('geometry outside any lodN element goes to blocks with lod null', () => {
const result = parse(cityModel(`<dem:ReliefFeature><dem:lod>2</dem:lod><dem:reliefComponent><dem:TINRelief><dem:tin>
<gml:TriangulatedSurface><gml:trianglePatches>${triangle('0 0 0 1 0 0 0 1 0 0 0 0')}</gml:trianglePatches></gml:TriangulatedSurface>
</dem:tin></dem:TINRelief></dem:reliefComponent></dem:ReliefFeature>`));
assert.deepEqual(result.blocks.map(b => b.lod), [null]);
});
// Minimal stateful stand-in for a WebGL2 context. Records the state the
// renderer is responsible for: buffer contents, attribute bindings, draws.
class FakeGL {
constructor({ compileOk = true, linkOk = true, infoLog = '' } = {}) {
this.ARRAY_BUFFER = 0x8892;
this.STATIC_DRAW = 0x88E4;
this.FLOAT = 0x1406;
this.UNSIGNED_BYTE = 0x1401;
this.TRIANGLES = 0x0004;
this.VERTEX_SHADER = 0x8B31;
this.FRAGMENT_SHADER = 0x8B30;
this.COMPILE_STATUS = 0x8B81;
this.LINK_STATUS = 0x8B82;
this.compileOk = compileOk;
this.linkOk = linkOk;
this.infoLog = infoLog;
this.calls = [];
this.created = new Set();
this.deleted = new Set();
this.boundArrayBuffer = null;
this.boundVao = null;
this.draws = [];
this.nextId = 1;
}
_create(kind) {
const obj = { kind, id: this.nextId++ };
this.created.add(obj);
return obj;
}
_delete(obj) {
if (obj) this.deleted.add(obj);
}
createShader(type) { return Object.assign(this._create('shader'), { type }); }
shaderSource(shader, source) { shader.source = source; }
compileShader(shader) { this.calls.push(['compileShader', shader]); }
getShaderParameter(shader, pname) { return pname === this.COMPILE_STATUS ? this.compileOk : null; }
getShaderInfoLog() { return this.infoLog; }
deleteShader(shader) { this._delete(shader); }
createProgram() { return this._create('program'); }
attachShader(program, shader) { this.calls.push(['attachShader', program, shader]); }
bindAttribLocation(program, index, name) { this.calls.push(['bindAttribLocation', program, index, name]); }
linkProgram(program) { this.calls.push(['linkProgram', program]); }
getProgramParameter(program, pname) { return pname === this.LINK_STATUS ? this.linkOk : null; }
getProgramInfoLog() { return this.infoLog; }
deleteProgram(program) { this._delete(program); }
// A VAO remembers the attribute pointers set while it is bound.
createVertexArray() { return Object.assign(this._create('vao'), { attribs: new Map() }); }
bindVertexArray(vao) { this.boundVao = vao; }
deleteVertexArray(vao) { this._delete(vao); }
createBuffer() { return Object.assign(this._create('buffer'), { bytes: null, allocations: 0 }); }
bindBuffer(target, buffer) {
if (target === this.ARRAY_BUFFER) this.boundArrayBuffer = buffer;
}
// bufferData(target, size, usage) allocates; bufferData(target, data, usage) allocates and copies.
bufferData(target, sizeOrData) {
const buffer = this.boundArrayBuffer;
buffer.allocations++;
if (typeof sizeOrData === 'number') {
buffer.bytes = new Uint8Array(sizeOrData);
} else {
buffer.bytes = new Uint8Array(sizeOrData.buffer.slice(sizeOrData.byteOffset, sizeOrData.byteOffset + sizeOrData.byteLength));
}
}
bufferSubData(target, dstByteOffset, data) {
const buffer = this.boundArrayBuffer;
const src = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
if (dstByteOffset + src.length > buffer.bytes.length) {
throw new Error('bufferSubData out of range');
}
buffer.bytes.set(src, dstByteOffset);
}
enableVertexAttribArray() { }
vertexAttribPointer(index, size, type, normalized, stride, offset) {
this.boundVao.attribs.set(index, { buffer: this.boundArrayBuffer, size, type, normalized, stride, offset });
}
deleteBuffer(buffer) { this._delete(buffer); }
drawArrays(mode, first, count) { this.draws.push({ vao: this.boundVao, mode, first, count }); }
}
module.exports = { FakeGL };
const test = require('node:test');
const assert = require('node:assert/strict');
const Navigation = require('../public/navigation.js');
const PIXEL = 0, LINE = 1, PAGE = 2;
// One wheel notch: 100 px in Chrome/Edge, 3 lines in Firefox.
const NOTCH_PX = 100;
const near = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-9, `${actual} != ${expected}`);
test('far away a notch changes the distance by 10 %', () => {
near(Navigation.zoomDistance(1000, NOTCH_PX, PIXEL), 1100);
near(Navigation.zoomDistance(1000, -NOTCH_PX, PIXEL), 900);
});
test('close in a notch still moves by the minimum step', () => {
near(Navigation.zoomDistance(10, -NOTCH_PX, PIXEL), 10 - Navigation.MIN_ZOOM_STEP);
near(Navigation.zoomDistance(10, NOTCH_PX, PIXEL), 10 + Navigation.MIN_ZOOM_STEP);
});
test('small trackpad deltas get a proportionally small minimum step', () => {
near(Navigation.zoomDistance(10, -10, PIXEL), 10 - Navigation.MIN_ZOOM_STEP / 10);
});
test('zooming in stops at the minimum distance instead of passing the centre', () => {
near(Navigation.zoomDistance(Navigation.MIN_DISTANCE + 0.5, -NOTCH_PX, PIXEL), Navigation.MIN_DISTANCE);
near(Navigation.zoomDistance(500, -5000, PIXEL), Navigation.MIN_DISTANCE);
});
test('zooming out from the minimum distance works', () => {
near(Navigation.zoomDistance(Navigation.MIN_DISTANCE, NOTCH_PX, PIXEL), Navigation.MIN_DISTANCE + Navigation.MIN_ZOOM_STEP);
});
test('a Firefox notch in lines zooms like a Chrome notch in pixels', () => {
near(Navigation.zoomDistance(1000, 3, LINE), Navigation.zoomDistance(1000, NOTCH_PX, PIXEL));
near(Navigation.zoomDistance(10, -3, LINE), Navigation.zoomDistance(10, -NOTCH_PX, PIXEL));
});
test('a page-wise delta counts as several notches', () => {
assert.ok(Navigation.zoomDistance(1000, 1, PAGE) > Navigation.zoomDistance(1000, NOTCH_PX, PIXEL));
});
// --- projection and starting distance ------------------------------------
const { mat4, vec4 } = require('../public/gl-matrix-min.js');
// 24-bit depth value of a point straight ahead at viewing distance z.
function depth24(planes, z) {
const proj = mat4.create();
mat4.perspective(proj, Navigation.FIELD_OF_VIEW, 1.5, planes.near, planes.far);
const clip = vec4.transformMat4(vec4.create(), vec4.fromValues(0, 0, -z, 1), proj);
const ndc = clip[2] / clip[3];
return Math.round((ndc * 0.5 + 0.5) * (2 ** 24 - 1));
}
// True if 20 points spaced step apart from z on all get distinct depth values.
function resolves(planes, z, step) {
const values = Array.from({ length: 20 }, (_, k) => depth24(planes, z + k * step));
return new Set(values).size === values.length;
}
test('the far plane is infinite so that everything in front of the camera is visible', () => {
for (const distance of [2, 500, 50000]) {
assert.equal(Navigation.clipPlanes(distance).far, Infinity);
}
});
test('depth resolves distance/2000 at the target and distance/200 at ten times the target distance', () => {
// At 1 km that is 0.5 m and 5 m, about the spacing of coplanar layers such as
// terrain, land use and ground surfaces; it stays below the size of a pixel
// (about distance/400 at 90 deg and 800 px) at any zoom level.
for (const distance of [2, 50, 1000, 10000, 100000]) {
const planes = Navigation.clipPlanes(distance);
const atTarget = distance / 2000, farAway = distance / 200;
assert.ok(resolves(planes, distance, atTarget), `${atTarget} m at ${distance} m`);
assert.ok(resolves(planes, 10 * distance, farAway), `${farAway} m at ${10 * distance} m`);
}
});
test('a fixed near plane of 1 m would not resolve coplanar layers far away', () => {
// Guards the depth test above: the old settings must fail it at 10 km.
const old = { near: 1, far: Infinity };
assert.equal(resolves(old, 10000, 10000 / 2000), false);
});
test('the near plane keeps geometry close to the camera visible', () => {
// A roof 1/50 of the distance in front of the camera must not be clipped,
// and at the minimum distance the near plane is at most 0.1 m.
for (const distance of [100, 10000]) {
assert.ok(Navigation.clipPlanes(distance).near <= distance / 50);
}
assert.ok(Navigation.clipPlanes(Navigation.MIN_DISTANCE).near <= 0.1);
});
test('the starting distance fits the whole model into a landscape view', () => {
// Bounding sphere radius 500 m, 90 deg vertical field of view: 500 / sin(45 deg).
const d = Navigation.fitDistance(1000, 2);
assert.ok(Math.abs(d - 500 / Math.sin(Math.PI / 4)) < 1e-6, `${d}`);
});
test('in a portrait view the narrower horizontal field of view decides', () => {
// Aspect 0.5: horizontal half angle atan(tan(45 deg) * 0.5) = 26.57 deg.
const d = Navigation.fitDistance(1000, 0.5);
assert.ok(Math.abs(d - 500 / Math.sin(Math.atan(0.5))) < 1e-6, `${d}`);
});
test('large areas are not capped', () => {
// A 3D-DLM of a whole district can be 50 km across.
assert.ok(Navigation.fitDistance(50000, 2) > 35000);
});
test('a model without extent starts at the minimum distance', () => {
assert.equal(Navigation.fitDistance(0, 2), Navigation.MIN_DISTANCE);
});
// --- panning -------------------------------------------------------------
test('panning moves the target with the mouse: one pixel is the view height at the target over the canvas height', () => {
// 90 deg vertical field of view: the view is 2 * distance high at the target.
const perPixel = Navigation.panPerPixel(1000, 600);
assert.ok(Math.abs(perPixel - 2 * 1000 / 600) < 1e-9, `${perPixel}`);
});
test('panning gets faster with distance', () => {
assert.ok(Math.abs(Navigation.panPerPixel(10000, 600) - 10 * Navigation.panPerPixel(1000, 600)) < 1e-9);
});
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}`);
});
const test = require('node:test');
const assert = require('node:assert/strict');
const { FakeGL } = require('./fake-gl.js');
const renderer = require('../public/renderer.js');
// A block as delivered by CityGML.createParser: fixed capacity, filled up to count.
function block(id, lod, capacity) {
const b = {
id, lod, capacity, count: 0,
positions: new Float32Array(capacity * 3),
colors: new Uint8Array(capacity * 4),
};
for (let i = 0; i < b.positions.length; i++) b.positions[i] = id * 1000 + i;
for (let i = 0; i < b.colors.length; i++) b.colors[i] = (id * 7 + i) % 256;
return b;
}
function floatsOf(buffer, vertices) {
return Array.from(new Float32Array(buffer.bytes.buffer, 0, vertices * 3));
}
test('createProgram binds position to 0 and color to 1 before linking', () => {
const gl = new FakeGL();
const program = renderer.createProgram(gl, 'vs', 'fs');
const bindPos = gl.calls.findIndex(c => c[0] === 'bindAttribLocation' && c[1] === program && c[2] === 0 && c[3] === 'position');
const bindColor = gl.calls.findIndex(c => c[0] === 'bindAttribLocation' && c[1] === program && c[2] === 1 && c[3] === 'color');
const link = gl.calls.findIndex(c => c[0] === 'linkProgram' && c[1] === program);
assert.ok(bindPos >= 0, 'position not bound to 0');
assert.ok(bindColor >= 0, 'color not bound to 1');
assert.ok(bindPos < link && bindColor < link, 'attribute locations must be bound before linking');
});
test('createProgram throws with the info log when a shader does not compile', () => {
const gl = new FakeGL({ compileOk: false, infoLog: "ERROR: 0:3: 'vec5' : syntax error" });
assert.throws(() => renderer.createProgram(gl, 'vs', 'fs'), /vec5/);
});
test('createProgram throws with the info log when the program does not link', () => {
const gl = new FakeGL({ linkOk: false, infoLog: 'varying interpolatedColor not written' });
assert.throws(() => renderer.createProgram(gl, 'vs', 'fs'), /interpolatedColor/);
});
test('updateBlock feeds positions to attribute 0 as floats and colors to attribute 1 as normalized bytes', () => {
const gl = new FakeGL();
const scene = renderer.createScene();
const b = block(1, 2, 6);
b.count = 6;
renderer.updateBlock(gl, scene, b);
renderer.drawScene(gl, scene, () => true);
const attribs = gl.draws[0].vao.attribs;
assert.deepEqual(
{ size: attribs.get(0).size, type: attribs.get(0).type, normalized: attribs.get(0).normalized },
{ size: 3, type: gl.FLOAT, normalized: false });
assert.deepEqual(
{ size: attribs.get(1).size, type: attribs.get(1).type, normalized: attribs.get(1).normalized, stride: attribs.get(1).stride },
{ size: 3, type: gl.UNSIGNED_BYTE, normalized: true, stride: 4 });
assert.deepEqual(floatsOf(attribs.get(0).buffer, 6), Array.from(b.positions));
assert.deepEqual(Array.from(attribs.get(1).buffer.bytes), Array.from(b.colors));
});
test('a growing block is appended to one GPU buffer allocated at full capacity', () => {
const gl = new FakeGL();
const scene = renderer.createScene();
const b = block(1, 2, 9);
b.count = 3;
renderer.updateBlock(gl, scene, b);
b.count = 9;
renderer.updateBlock(gl, scene, b);
renderer.drawScene(gl, scene, () => true);
const posBuffer = gl.draws[0].vao.attribs.get(0).buffer;
assert.equal(posBuffer.allocations, 1, 'buffer must be allocated once, not re-created per update');
assert.equal(posBuffer.bytes.length, 9 * 3 * 4);
assert.deepEqual(floatsOf(posBuffer, 9), Array.from(b.positions));
assert.deepEqual(gl.draws.map(d => d.count), [9]);
});
test('drawScene draws only the filled part of each block', () => {
const gl = new FakeGL();
const scene = renderer.createScene();
const b = block(1, 2, 300);
b.count = 6;
renderer.updateBlock(gl, scene, b);
renderer.drawScene(gl, scene, () => true);
assert.deepEqual(gl.draws.map(d => [d.mode, d.first, d.count]), [[gl.TRIANGLES, 0, 6]]);
});
test('drawScene skips blocks of hidden LODs', () => {
const gl = new FakeGL();
const scene = renderer.createScene();
const lod1 = block(1, 1, 3);
const lod2 = block(2, 2, 6);
const noLod = block(3, null, 3);
lod1.count = 3; lod2.count = 6; noLod.count = 3;
for (const b of [lod1, lod2, noLod]) renderer.updateBlock(gl, scene, b);
renderer.drawScene(gl, scene, lod => lod === 2);
assert.deepEqual(gl.draws.map(d => d.count), [6]);
});
test('deleteScene frees every GPU object the scene created', () => {
const gl = new FakeGL();
const scene = renderer.createScene();
const a = block(1, 1, 3);
const b = block(2, 2, 3);
a.count = 3; b.count = 3;
renderer.updateBlock(gl, scene, a);
renderer.updateBlock(gl, scene, b);
const createdByScene = [...gl.created];
renderer.deleteScene(gl, scene);
assert.equal(createdByScene.length, 6, 'expected a VAO and two buffers per block');
for (const obj of createdByScene) {
assert.ok(gl.deleted.has(obj), `${obj.kind} ${obj.id} not deleted`);
}
});
// Builds zip archives in memory for tests. Supports stored and deflated
// entries, directory entries and ZIP64 records (forced, even for small data).
const zlib = require('node:zlib');
const enc = new TextEncoder();
function bytes(data) {
return typeof data === 'string' ? enc.encode(data) : data;
}
// entries: [{ name, data?, method?: 0|8 (default 8), dir?: true,
// flags?: number, rawMethod?: number }]
// rawMethod writes that method id without compressing (for unsupported-method tests).
function makeZip(entries, { zip64 = false } = {}) {
const parts = [];
const central = [];
let offset = 0;
for (const e of entries) {
const name = enc.encode(e.dir ? e.name.replace(/\/?$/, '/') : e.name);
const raw = e.dir ? new Uint8Array(0) : bytes(e.data);
const method = e.rawMethod !== undefined ? e.rawMethod : e.dir ? 0 : (e.method === undefined ? 8 : e.method);
const payload = method === 8 && e.rawMethod === undefined ? zlib.deflateRawSync(raw) : raw;
const crc = zlib.crc32(raw);
const flags = (e.flags || 0) | 0x0800;
const localExtra = zip64 ? zip64Extra([raw.length, payload.length]) : new Uint8Array(0);
const local = new DataView(new ArrayBuffer(30));
local.setUint32(0, 0x04034b50, true);
local.setUint16(4, zip64 ? 45 : 20, true);
local.setUint16(6, flags, true);
local.setUint16(8, method, true);
local.setUint32(14, crc, true);
local.setUint32(18, zip64 ? 0xFFFFFFFF : payload.length, true);
local.setUint32(22, zip64 ? 0xFFFFFFFF : raw.length, true);
local.setUint16(26, name.length, true);
local.setUint16(28, localExtra.length, true);
parts.push(new Uint8Array(local.buffer), name, localExtra, payload);
const centralExtra = zip64 ? zip64Extra([raw.length, payload.length, offset]) : new Uint8Array(0);
const cd = new DataView(new ArrayBuffer(46));
cd.setUint32(0, 0x02014b50, true);
cd.setUint16(4, 45, true);
cd.setUint16(6, zip64 ? 45 : 20, true);
cd.setUint16(8, flags, true);
cd.setUint16(10, method, true);
cd.setUint32(16, crc, true);
cd.setUint32(20, zip64 ? 0xFFFFFFFF : payload.length, true);
cd.setUint32(24, zip64 ? 0xFFFFFFFF : raw.length, true);
cd.setUint16(28, name.length, true);
cd.setUint16(30, centralExtra.length, true);
cd.setUint32(38, e.dir ? 0x10 : 0, true);
cd.setUint32(42, zip64 ? 0xFFFFFFFF : offset, true);
central.push(new Uint8Array(cd.buffer), name, centralExtra);
offset += 30 + name.length + localExtra.length + payload.length;
}
const cdOffset = offset;
const cdSize = central.reduce((n, p) => n + p.length, 0);
parts.push(...central);
offset += cdSize;
if (zip64) {
const rec = new DataView(new ArrayBuffer(56));
rec.setUint32(0, 0x06064b50, true);
setUint64(rec, 4, 44);
rec.setUint16(12, 45, true);
rec.setUint16(14, 45, true);
setUint64(rec, 24, entries.length);
setUint64(rec, 32, entries.length);
setUint64(rec, 40, cdSize);
setUint64(rec, 48, cdOffset);
const locator = new DataView(new ArrayBuffer(20));
locator.setUint32(0, 0x07064b50, true);
setUint64(locator, 8, offset);
locator.setUint32(16, 1, true);
parts.push(new Uint8Array(rec.buffer), new Uint8Array(locator.buffer));
}
const eocd = new DataView(new ArrayBuffer(22));
eocd.setUint32(0, 0x06054b50, true);
eocd.setUint16(8, zip64 ? 0xFFFF : entries.length, true);
eocd.setUint16(10, zip64 ? 0xFFFF : entries.length, true);
eocd.setUint32(12, zip64 ? 0xFFFFFFFF : cdSize, true);
eocd.setUint32(16, zip64 ? 0xFFFFFFFF : cdOffset, true);
parts.push(new Uint8Array(eocd.buffer));
return new Blob(parts);
}
function setUint64(view, pos, value) {
view.setUint32(pos, value % 0x100000000, true);
view.setUint32(pos + 4, Math.floor(value / 0x100000000), true);
}
function zip64Extra(values) {
const view = new DataView(new ArrayBuffer(4 + 8 * values.length));
view.setUint16(0, 0x0001, true);
view.setUint16(2, 8 * values.length, true);
values.forEach((v, i) => setUint64(view, 4 + 8 * i, v));
return new Uint8Array(view.buffer);
}
module.exports = { makeZip };
const test = require('node:test');
const assert = require('node:assert/strict');
const { makeZip } = require('./zip-util.js');
const ZipReader = require('../public/zipreader.js');
const CityGML = require('../public/citygml.js');
async function readAll(stream) {
const chunks = [];
for await (const c of stream) chunks.push(c);
return Buffer.concat(chunks).toString('utf8');
}
const TEXT = 'Grüße aus Stuttgart\n'.repeat(500);
test('listEntries returns names and sizes from the central directory', async () => {
const zip = makeZip([
{ name: 'data', dir: true },
{ name: 'data/city.gml', data: TEXT },
{ name: 'readme.txt', data: 'hello', method: 0 },
]);
const entries = await ZipReader.listEntries(zip);
assert.deepEqual(entries.map(e => [e.name, e.directory, e.uncompressedSize]), [
['data/', true, 0],
['data/city.gml', false, Buffer.byteLength(TEXT)],
['readme.txt', false, 5],
]);
});
test('a stored entry is read back unchanged', async () => {
const zip = makeZip([{ name: 'a.gml', data: TEXT, method: 0 }]);
const [entry] = await ZipReader.listEntries(zip);
assert.equal(await readAll(await ZipReader.openEntry(zip, entry)), TEXT);
});
test('a deflated entry is decompressed', async () => {
const zip = makeZip([{ name: 'readme.txt', data: 'x' }, { name: 'a.gml', data: TEXT }]);
const entries = await ZipReader.listEntries(zip);
assert.ok(entries[1].compressedSize < entries[1].uncompressedSize, 'fixture should be compressed');
assert.equal(await readAll(await ZipReader.openEntry(zip, entries[1])), TEXT);
});
test('ZIP64 archives are listed and read', async () => {
const zip = makeZip([{ name: 'readme.txt', data: 'x' }, { name: 'big.gml', data: TEXT }], { zip64: true });
const entries = await ZipReader.listEntries(zip);
assert.deepEqual(entries.map(e => [e.name, e.uncompressedSize]), [['readme.txt', 1], ['big.gml', Buffer.byteLength(TEXT)]]);
assert.equal(await readAll(await ZipReader.openEntry(zip, entries[1])), TEXT);
});
test('a file that is not a zip archive is rejected', async () => {
await assert.rejects(ZipReader.listEntries(new Blob(['<CityModel/>'])), /not a zip/i);
});
test('progress counts compressed bytes up to 1', async () => {
const zip = makeZip([{ name: 'a.gml', data: TEXT }]);
const [entry] = await ZipReader.listEntries(zip);
const progress = [];
await readAll(await ZipReader.openEntry(zip, entry, { onProgress: p => progress.push(p) }));
assert.ok(progress.length > 0);
assert.equal(progress[progress.length - 1], 1);
assert.ok(progress.every((p, i) => i === 0 || p >= progress[i - 1]), 'progress must not decrease');
});
test('an encrypted entry is rejected with a clear message', async () => {
const zip = makeZip([{ name: 'a.gml', data: TEXT, method: 0, flags: 0x1 }]);
const [entry] = await ZipReader.listEntries(zip);
await assert.rejects(ZipReader.openEntry(zip, entry), /encrypted/i);
});
test('an unsupported compression method is rejected with its name', async () => {
const zip = makeZip([{ name: 'a.gml', data: 'whatever', rawMethod: 9 }]);
const [entry] = await ZipReader.listEntries(zip);
await assert.rejects(ZipReader.openEntry(zip, entry), /Deflate64/);
});
// --- choosing the CityGML entry -------------------------------------------
function entriesNamed(...names) {
return names.map(name => ({ name, directory: name.endsWith('/') }));
}
test('the single .gml file is chosen among other files', () => {
const entry = ZipReader.findCityGmlEntry(entriesNamed('docs/', 'docs/readme.txt', 'metadata.xml', 'data/LoD2_city.GML'));
assert.equal(entry.name, 'data/LoD2_city.GML');
});
test('macOS resource fork copies are ignored', () => {
const entry = ZipReader.findCityGmlEntry(entriesNamed('__MACOSX/._city.gml', 'city.gml'));
assert.equal(entry.name, 'city.gml');
});
test('without .gml files a single .xml file is chosen', () => {
const entry = ZipReader.findCityGmlEntry(entriesNamed('LoD2_282_5602_2_SN.xml', 'readme.txt'));
assert.equal(entry.name, 'LoD2_282_5602_2_SN.xml');
});
test('several .gml files are rejected with their names', () => {
assert.throws(() => ZipReader.findCityGmlEntry(entriesNamed('a.gml', 'b.gml')), /a\.gml.*b\.gml/s);
});
test('an archive without CityGML is rejected', () => {
assert.throws(() => ZipReader.findCityGmlEntry(entriesNamed('readme.txt', 'a.xml', 'b.xml')), /no CityGML/i);
});
// --- end to end ------------------------------------------------------------
test('a CityGML file inside a zip is parsed through readStream', async () => {
const city = `<core:CityModel><gml:Polygon><gml:exterior><gml:LinearRing><gml:posList>
0 0 0 1 0 0 1 1 0 0 1 0 0 0 0</gml:posList></gml:LinearRing></gml:exterior></gml:Polygon></core:CityModel>`;
const zip = makeZip([{ name: 'readme.txt', data: 'x' }, { name: 'city.gml', data: city }]);
const entry = ZipReader.findCityGmlEntry(await ZipReader.listEntries(zip));
const summary = await CityGML.readStream(await ZipReader.openEntry(zip, entry), CityGML.createParser());
assert.equal(summary.vertexCount, 6);
});
test('readStream lets timers run while reading a stream whose data is always ready', async () => {
// Like a decompression stream: every read resolves at once, without I/O.
const chunk = new TextEncoder().encode(' '.repeat(1024));
let chunks = 0;
const stream = new ReadableStream({
pull(controller) {
if (chunks++ < 400) controller.enqueue(chunk);
else controller.close();
}
});
let timerRanAtChunk = null;
setTimeout(() => { timerRanAtChunk = chunks; }, 0);
await CityGML.readStream(stream, CityGML.createParser(), { flushBytes: 1024 });
assert.ok(timerRanAtChunk !== null && timerRanAtChunk < 400,
`timer only ran after reading finished (at chunk ${timerRanAtChunk})`);
});
test('readStream rejects with AbortError when aborted', async () => {
const controller = new AbortController();
controller.abort();
const zip = makeZip([{ name: 'city.gml', data: TEXT }]);
const [entry] = await ZipReader.listEntries(zip);
await assert.rejects(
CityGML.readStream(await ZipReader.openEntry(zip, entry), CityGML.createParser(), { signal: controller.signal }),
{ name: 'AbortError' });
});
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