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

move viewer code to ES modules under public/src, vendor libraries to public/vendor



Co-Authored-By: default avatarClaude Opus 5.5 (1M context) <noreply@anthropic.com>
parent 6360fdfb
{
"name": "citygml-viewer",
"private": true,
"type": "module",
"scripts": {
"test": "node --test \"tests/*.test.js\""
"test": "node --import ./tests/setup-globals.js --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.fromValues(1, 0, 0);
}
// vec3.create() takes no arguments; clone copies the points.
return calculateNormalWithCross(vec3.clone(ring[0]), vec3.clone(ring[1]), vec3.clone(ring[2]));
}
var v = vec3.fromValues(coords[0], coords[1], coords[2]);
vec3.normalize(v, v);
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
};
});
// 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;
}
// Tracks a mouse drag that started on the canvas. move() returns the
// movement since the last position, or null when no drag is active, so a
// drag entering the canvas from outside is ignored.
function createDragTracker() {
var active = false;
var mode = null;
var lastX = 0;
var lastY = 0;
return {
start: function (x, y, dragMode) {
active = true;
mode = dragMode;
lastX = x;
lastY = y;
},
move: function (x, y) {
if (!active) {
return null;
}
var delta = { mode: mode, dx: x - lastX, dy: y - lastY };
lastX = x;
lastY = y;
return delta;
},
end: function () {
active = false;
}
};
}
return {
MIN_ZOOM_STEP: MIN_ZOOM_STEP,
MIN_DISTANCE: MIN_DISTANCE,
FIELD_OF_VIEW: FIELD_OF_VIEW,
zoomDistance: zoomDistance,
clipPlanes: clipPlanes,
fitDistance: fitDistance,
panPerPixel: panPerPixel,
createDragTracker: createDragTracker
};
});
// 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);
}
// Canvas size from a ResizeObserver entry: width/height in device pixels
// for a sharp image on high-resolution screens, cssWidth/cssHeight in CSS
// pixels as used by mouse events.
function canvasPixelSize(entry, devicePixelRatio) {
var cssWidth, cssHeight;
if (entry.contentBoxSize) {
var box = entry.contentBoxSize[0] || entry.contentBoxSize;
cssWidth = box.inlineSize;
cssHeight = box.blockSize;
} else {
cssWidth = entry.contentRect.width;
cssHeight = entry.contentRect.height;
}
return {
width: Math.round(cssWidth * devicePixelRatio),
height: Math.round(cssHeight * devicePixelRatio),
cssWidth: cssWidth,
cssHeight: cssHeight
};
}
return {
canvasPixelSize: canvasPixelSize,
createProgram: createProgram,
createScene: createScene,
updateBlock: updateBlock,
deleteScene: deleteScene,
drawScene: drawScene
};
});
// CityGML parsing and mesh building, independent of the DOM and WebGL.
import { sax, libtess, glMatrix } from '../vendor.js';
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.fromValues(1, 0, 0);
}
// vec3.create() takes no arguments; clone copies the points.
return calculateNormalWithCross(vec3.clone(ring[0]), vec3.clone(ring[1]), vec3.clone(ring[2]));
}
var v = vec3.fromValues(coords[0], coords[1], coords[2]);
vec3.normalize(v, v);
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;
})();
export { BBox, createParser, readStream, readFile };
// 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).
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") : ""));
}
export { listEntries, openEntry, findCityGmlEntry };
// The vendor libraries are classic scripts (see index.html) that define
// globals; this is the only module that reads them.
const g = globalThis;
if (!g.sax || !g.libtess || !g.glMatrix) {
throw new Error('sax.js, libtess.min.js and gl-matrix-min.js must be loaded before the modules');
}
export const sax = g.sax;
export const libtess = g.libtess;
export const glMatrix = g.glMatrix;
export const { mat4, vec2, vec3, vec4 } = g.glMatrix;
// Camera navigation rules that do not depend on the DOM or WebGL.
// 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;
}
// Tracks a mouse drag that started on the canvas. move() returns the
// movement since the last position, or null when no drag is active, so a
// drag entering the canvas from outside is ignored.
function createDragTracker() {
var active = false;
var mode = null;
var lastX = 0;
var lastY = 0;
return {
start: function (x, y, dragMode) {
active = true;
mode = dragMode;
lastX = x;
lastY = y;
},
move: function (x, y) {
if (!active) {
return null;
}
var delta = { mode: mode, dx: x - lastX, dy: y - lastY };
lastX = x;
lastY = y;
return delta;
},
end: function () {
active = false;
}
};
}
export { MIN_ZOOM_STEP, MIN_DISTANCE, FIELD_OF_VIEW, zoomDistance, clipPlanes, fitDistance, panPerPixel, createDragTracker };
// WebGL2 helpers for the CityGML viewer: shader program, mesh upload, drawing.
// 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);
}
// Canvas size from a ResizeObserver entry: width/height in device pixels
// for a sharp image on high-resolution screens, cssWidth/cssHeight in CSS
// pixels as used by mouse events.
function canvasPixelSize(entry, devicePixelRatio) {
var cssWidth, cssHeight;
if (entry.contentBoxSize) {
var box = entry.contentBoxSize[0] || entry.contentBoxSize;
cssWidth = box.inlineSize;
cssHeight = box.blockSize;
} else {
cssWidth = entry.contentRect.width;
cssHeight = entry.contentRect.height;
}
return {
width: Math.round(cssWidth * devicePixelRatio),
height: Math.round(cssHeight * devicePixelRatio),
cssWidth: cssWidth,
cssHeight: cssHeight
};
}
export { canvasPixelSize, createProgram, createScene, updateBlock, deleteScene, drawScene };
// 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');
import test from 'node:test';
import assert from 'node:assert/strict';
import * as CityGML from '../public/src/loading/citygml.js';
function cityModel(body) {
return `<?xml version="1.0" encoding="UTF-8"?>
......
// Minimal stateful stand-in for a WebGL2 context. Records the state the
// renderer is responsible for: buffer contents, attribute bindings, draws.
class FakeGL {
export class FakeGL {
constructor({ compileOk = true, linkOk = true, infoLog = '' } = {}) {
this.ARRAY_BUFFER = 0x8892;
this.STATIC_DRAW = 0x88E4;
......@@ -83,5 +83,3 @@ class FakeGL {
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');
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Navigation from '../public/src/viewer/navigation.js';
const PIXEL = 0, LINE = 1, PAGE = 2;
// One wheel notch: 100 px in Chrome/Edge, 3 lines in Firefox.
......@@ -42,7 +42,7 @@ test('a page-wise delta counts as several notches', () => {
// --- projection and starting distance ------------------------------------
const { mat4, vec4 } = require('../public/gl-matrix-min.js');
const { mat4, vec4 } = globalThis.glMatrix;
// 24-bit depth value of a point straight ahead at viewing distance z.
function depth24(planes, z) {
......
const test = require('node:test');
const assert = require('node:assert/strict');
const { FakeGL } = require('./fake-gl.js');
const renderer = require('../public/renderer.js');
import test from 'node:test';
import assert from 'node:assert/strict';
import { FakeGL } from './fake-gl.js';
import * as renderer from '../public/src/viewer/renderer.js';
// A block as delivered by CityGML.createParser: fixed capacity, filled up to count.
function block(id, lod, capacity) {
......
// Loads the vendor libraries the way the browser does: as classic scripts
// that define globals (sax, libtess, glMatrix). Runs before any test module.
import fs from 'node:fs';
import vm from 'node:vm';
const vendor = new URL('../public/vendor/', import.meta.url);
for (const name of ['sax.js', 'libtess.min.js', 'gl-matrix-min.js']) {
vm.runInThisContext(fs.readFileSync(new URL(name, vendor), 'utf8'), { filename: name });
}
// 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');
import zlib from 'node:zlib';
const enc = new TextEncoder();
......@@ -11,7 +11,7 @@ function bytes(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 } = {}) {
export function makeZip(entries, { zip64 = false } = {}) {
const parts = [];
const central = [];
let offset = 0;
......@@ -101,5 +101,3 @@ function zip64Extra(values) {
values.forEach((v, i) => setUint64(view, 4 + 8 * i, v));
return new Uint8Array(view.buffer);
}
module.exports = { makeZip };
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