maplibre-copc-layer 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,645 @@
1
+ import maplibregl from "maplibre-gl";
2
+ import * as THREE from "three";
3
+ //#region src/cache-manager.ts
4
+ var CacheManager = class CacheManager {
5
+ cache = /* @__PURE__ */ new Map();
6
+ memoryUsage = 0;
7
+ options;
8
+ constructor(options = {}) {
9
+ this.options = {
10
+ maxNodes: options.maxNodes ?? 100,
11
+ maxMemoryBytes: options.maxMemoryBytes ?? 100 * 1024 * 1024,
12
+ debug: options.debug ?? false
13
+ };
14
+ }
15
+ get(nodeId) {
16
+ const data = this.cache.get(nodeId);
17
+ if (data) {
18
+ this.cache.delete(nodeId);
19
+ this.cache.set(nodeId, data);
20
+ data.lastAccessed = Date.now();
21
+ return data;
22
+ }
23
+ return null;
24
+ }
25
+ set(nodeData, protectedNodes) {
26
+ const { nodeId } = nodeData;
27
+ const existing = this.cache.get(nodeId);
28
+ if (existing) {
29
+ this.disposeNodeResources(existing);
30
+ this.memoryUsage -= existing.sizeBytes;
31
+ this.cache.delete(nodeId);
32
+ }
33
+ this.ensureCacheLimits(nodeData.sizeBytes, protectedNodes);
34
+ nodeData.lastAccessed = Date.now();
35
+ this.cache.set(nodeId, nodeData);
36
+ this.memoryUsage += nodeData.sizeBytes;
37
+ this.log(`Cached node ${nodeId} (${this.formatBytes(nodeData.sizeBytes)})`);
38
+ }
39
+ has(nodeId) {
40
+ return this.cache.has(nodeId);
41
+ }
42
+ delete(nodeId) {
43
+ const data = this.cache.get(nodeId);
44
+ if (!data) return false;
45
+ this.disposeNodeResources(data);
46
+ this.cache.delete(nodeId);
47
+ this.memoryUsage -= data.sizeBytes;
48
+ this.log(`Removed node ${nodeId} from cache`);
49
+ return true;
50
+ }
51
+ clear() {
52
+ for (const data of this.cache.values()) this.disposeNodeResources(data);
53
+ this.cache.clear();
54
+ this.memoryUsage = 0;
55
+ }
56
+ updateOptions(newOptions, protectedNodes) {
57
+ Object.assign(this.options, newOptions);
58
+ this.ensureCacheLimits(0, protectedNodes);
59
+ }
60
+ getCachedNodeIds() {
61
+ return Array.from(this.cache.keys());
62
+ }
63
+ size() {
64
+ return this.cache.size;
65
+ }
66
+ static estimateNodeSize(positions, colors) {
67
+ return positions.length * (positions instanceof Float64Array ? 8 : 4) + colors.length * 4 + 1024;
68
+ }
69
+ static createNodeData(nodeId, positions, colors, materialConfig) {
70
+ return {
71
+ nodeId,
72
+ positions: new Float64Array(positions),
73
+ colors: new Float32Array(colors),
74
+ pointCount: positions.length / 3,
75
+ materialConfig: { ...materialConfig },
76
+ lastAccessed: Date.now(),
77
+ sizeBytes: CacheManager.estimateNodeSize(positions, colors)
78
+ };
79
+ }
80
+ ensureCacheLimits(newItemSize, protectedNodes) {
81
+ while (this.cache.size >= this.options.maxNodes || this.memoryUsage + newItemSize > this.options.maxMemoryBytes) {
82
+ if (this.cache.size === 0) break;
83
+ let lruNodeId = null;
84
+ for (const nodeId of this.cache.keys()) if (!protectedNodes?.has(nodeId)) {
85
+ lruNodeId = nodeId;
86
+ break;
87
+ }
88
+ if (!lruNodeId) {
89
+ this.log("Warning: Cannot evict any nodes - all are protected. Cache limit exceeded.");
90
+ break;
91
+ }
92
+ this.delete(lruNodeId);
93
+ }
94
+ }
95
+ disposeNodeResources(data) {
96
+ data.geometry?.dispose();
97
+ if (data.points?.material instanceof THREE.Material) data.points.material.dispose();
98
+ }
99
+ formatBytes(bytes) {
100
+ const sizes = [
101
+ "B",
102
+ "KB",
103
+ "MB",
104
+ "GB"
105
+ ];
106
+ if (bytes === 0) return "0 B";
107
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
108
+ return `${Math.round(bytes / 1024 ** i * 100) / 100} ${sizes[i]}`;
109
+ }
110
+ log(message) {
111
+ if (this.options.debug) console.log("[CacheManager]", message);
112
+ }
113
+ };
114
+ //#endregion
115
+ //#region src/shaders/points.vert.glsl
116
+ var points_vert_default = "uniform float size;\nuniform float scale;\n\n#ifdef USE_COLOR\n varying vec3 vColor;\n#endif\n\nvoid main() {\n #ifdef USE_COLOR\n vColor = color;\n #endif\n\n vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);\n gl_Position = projectionMatrix * mvPosition;\n\n gl_PointSize = size;\n\n #ifdef USE_SIZEATTENUATION\n gl_PointSize *= (scale / -mvPosition.z);\n #endif\n}\n";
117
+ //#endregion
118
+ //#region src/shaders/points.frag.glsl
119
+ var points_frag_default = "uniform vec3 pointColor;\n\n#ifdef USE_COLOR\n varying vec3 vColor;\n#endif\n\nvoid main() {\n vec2 cxy = 2.0 * gl_PointCoord - 1.0;\n float r = dot(cxy, cxy);\n if (r > 1.0) {\n discard;\n }\n\n #ifdef USE_COLOR\n gl_FragColor = vec4(vColor, 1.0);\n #else\n gl_FragColor = vec4(pointColor, 1.0);\n #endif\n}\n";
120
+ //#endregion
121
+ //#region src/shaders/edl.vert.glsl
122
+ var edl_vert_default = "varying vec2 vUv;\n\nvoid main() {\n vUv = uv;\n gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n}\n";
123
+ //#endregion
124
+ //#region src/shaders/edl.frag.glsl
125
+ var edl_frag_default = "uniform sampler2D tDepth;\nuniform sampler2D tColor;\nuniform vec2 screenSize;\nuniform float edlStrength;\nuniform float radius;\nvarying vec2 vUv;\n\nfloat readDepth(sampler2D depthSampler, vec2 coord) {\n return texture2D(depthSampler, coord).x;\n}\n\nvoid main() {\n vec4 color = texture2D(tColor, vUv);\n\n if (color.a == 0.0) {\n discard;\n }\n\n float depth = readDepth(tDepth, vUv);\n\n float response = 0.0;\n vec2 texelSize = 1.0 / screenSize;\n\n for (int i = -2; i <= 2; i++) {\n for (int j = -2; j <= 2; j++) {\n if (i == 0 && j == 0) continue;\n\n vec2 offset = vec2(float(i), float(j)) * texelSize * radius;\n vec2 sampleCoord = vUv + offset;\n\n if (sampleCoord.x < 0.0 || sampleCoord.x > 1.0 ||\n sampleCoord.y < 0.0 || sampleCoord.y > 1.0) {\n continue;\n }\n\n float sampleDepth = readDepth(tDepth, sampleCoord);\n float depthDiff = depth - sampleDepth;\n\n if (depthDiff > 0.0) {\n response += min(1.0, depthDiff * 100.0);\n }\n }\n }\n\n response /= 24.0;\n float edl = exp(-response * edlStrength);\n\n gl_FragColor = vec4(color.rgb * edl, color.a);\n}\n";
126
+ //#endregion
127
+ //#region src/copclayer.ts
128
+ const EARTH_CIRCUMFERENCE = 2 * Math.PI * 6378137;
129
+ const DEG2RAD = Math.PI / 180;
130
+ const DEFAULT_OPTIONS = {
131
+ pointSize: 6,
132
+ colorMode: "rgb",
133
+ maxCacheSize: 100,
134
+ sseThreshold: 8,
135
+ depthTest: true,
136
+ maxCacheMemory: 100 * 1024 * 1024,
137
+ debug: false,
138
+ enableEDL: false,
139
+ edlStrength: .4,
140
+ edlRadius: 1.5,
141
+ wasmPath: void 0,
142
+ onInitialized: () => {}
143
+ };
144
+ var CopcLayer = class {
145
+ id;
146
+ type = "custom";
147
+ renderingMode = "3d";
148
+ url;
149
+ map;
150
+ camera;
151
+ scene;
152
+ renderer;
153
+ worker;
154
+ cacheManager;
155
+ options;
156
+ visibleNodes = [];
157
+ workerInitialized = false;
158
+ pendingRequests = /* @__PURE__ */ new Set();
159
+ requestQueue = [];
160
+ lastCameraPosition = null;
161
+ sceneCenter = null;
162
+ colorTarget;
163
+ depthTarget;
164
+ edlMaterial;
165
+ edlQuadScene;
166
+ edlQuadCamera;
167
+ _tempMatrix1 = new THREE.Matrix4();
168
+ _tempMatrix2 = new THREE.Matrix4();
169
+ _lastEdlWidth = 0;
170
+ _lastEdlHeight = 0;
171
+ _lastUpdatePointsTime = 0;
172
+ constructor(url, options = {}, layerId = "copc-layer") {
173
+ this.id = layerId;
174
+ this.url = url;
175
+ this.options = {
176
+ ...DEFAULT_OPTIONS,
177
+ ...options
178
+ };
179
+ this.cacheManager = new CacheManager({
180
+ maxNodes: this.options.maxCacheSize,
181
+ maxMemoryBytes: this.options.maxCacheMemory,
182
+ debug: this.options.debug
183
+ });
184
+ this.camera = new THREE.Camera();
185
+ this.scene = new THREE.Scene();
186
+ this.worker = new Worker(new URL("./worker/index.ts", import.meta.url), { type: "module" });
187
+ this.setupWorkerMessageHandlers();
188
+ }
189
+ setupWorkerMessageHandlers() {
190
+ this.worker.onmessage = (event) => {
191
+ const message = event.data;
192
+ switch (message.type) {
193
+ case "initialized":
194
+ this.workerInitialized = true;
195
+ this.requestNodeData("0-0-0-0");
196
+ this.options.onInitialized?.(message);
197
+ break;
198
+ case "nodeLoaded":
199
+ this.handleNodeLoaded(message.node, message.positions, message.colors);
200
+ break;
201
+ case "nodesToLoad":
202
+ this.cancelAllPendingRequests();
203
+ this.lastCameraPosition = message.cameraPosition;
204
+ this.visibleNodes = message.nodes;
205
+ this.updateVisibleNodes();
206
+ break;
207
+ case "error":
208
+ if (this.options.debug) console.error("[CopcLayer] Worker error:", message.message);
209
+ break;
210
+ }
211
+ this.map?.triggerRepaint();
212
+ };
213
+ this.worker.onerror = (error) => {
214
+ if (this.options.debug) console.error("[CopcLayer] Worker error event:", error);
215
+ };
216
+ }
217
+ handleNodeLoaded(nodeId, positionsBuffer, colorsBuffer) {
218
+ this.pendingRequests.delete(nodeId);
219
+ this.removeFromRequestQueue(nodeId);
220
+ const positions = new Float64Array(positionsBuffer);
221
+ const colors = new Float32Array(colorsBuffer);
222
+ const nodeData = CacheManager.createNodeData(nodeId, positions, colors, {
223
+ colorMode: this.options.colorMode,
224
+ pointSize: this.options.pointSize,
225
+ depthTest: this.options.depthTest
226
+ });
227
+ const geometry = new THREE.BufferGeometry();
228
+ if (this.sceneCenter) {
229
+ const { x: cx, y: cy, z: cz } = this.sceneCenter;
230
+ const relativePositions = new Float32Array(positions.length);
231
+ for (let i = 0; i < positions.length; i += 3) {
232
+ relativePositions[i] = positions[i] - cx;
233
+ relativePositions[i + 1] = positions[i + 1] - cy;
234
+ relativePositions[i + 2] = positions[i + 2] - cz;
235
+ }
236
+ geometry.setAttribute("position", new THREE.BufferAttribute(relativePositions, 3));
237
+ } else geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(positions), 3));
238
+ geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
239
+ const material = this.createPointMaterial();
240
+ const points = new THREE.Points(geometry, material);
241
+ nodeData.geometry = geometry;
242
+ nodeData.points = points;
243
+ const protectedNodes = new Set(this.visibleNodes);
244
+ this.cacheManager.set(nodeData, protectedNodes);
245
+ }
246
+ updateVisibleNodes() {
247
+ while (this.scene.children.length > 0) this.scene.remove(this.scene.children[0]);
248
+ const nodesToRequest = [];
249
+ for (const nodeId of this.visibleNodes) {
250
+ const cachedData = this.cacheManager.get(nodeId);
251
+ if (cachedData?.points) {
252
+ this.scene.add(cachedData.points);
253
+ if (this.needsMaterialUpdate(cachedData)) this.updateNodeMaterial(cachedData);
254
+ } else if (!this.pendingRequests.has(nodeId)) nodesToRequest.push(nodeId);
255
+ }
256
+ for (const nodeId of this.prioritizeNodeRequests(nodesToRequest)) this.requestNodeData(nodeId);
257
+ }
258
+ requestNodeData(nodeId) {
259
+ if (this.pendingRequests.has(nodeId)) return;
260
+ this.pendingRequests.add(nodeId);
261
+ this.requestQueue.push(nodeId);
262
+ this.worker.postMessage({
263
+ type: "loadNode",
264
+ node: nodeId
265
+ });
266
+ }
267
+ needsMaterialUpdate(nodeData) {
268
+ const { materialConfig: c } = nodeData;
269
+ return c.colorMode !== this.options.colorMode || c.pointSize !== this.options.pointSize || c.depthTest !== this.options.depthTest;
270
+ }
271
+ updateNodeMaterial(nodeData) {
272
+ if (!nodeData.points) return;
273
+ if (nodeData.points.material instanceof THREE.Material) nodeData.points.material.dispose();
274
+ nodeData.points.material = this.createPointMaterial();
275
+ nodeData.materialConfig = {
276
+ colorMode: this.options.colorMode,
277
+ pointSize: this.options.pointSize,
278
+ depthTest: this.options.depthTest
279
+ };
280
+ }
281
+ rebuildAllMaterials() {
282
+ for (const nodeId of this.cacheManager.getCachedNodeIds()) {
283
+ const nodeData = this.cacheManager.get(nodeId);
284
+ if (nodeData) this.updateNodeMaterial(nodeData);
285
+ }
286
+ }
287
+ removeFromRequestQueue(nodeId) {
288
+ const index = this.requestQueue.indexOf(nodeId);
289
+ if (index > -1) this.requestQueue.splice(index, 1);
290
+ }
291
+ cancelAllPendingRequests() {
292
+ if (this.pendingRequests.size === 0) return;
293
+ this.worker.postMessage({
294
+ type: "cancelRequests",
295
+ nodes: Array.from(this.pendingRequests)
296
+ });
297
+ this.pendingRequests.clear();
298
+ this.requestQueue.length = 0;
299
+ }
300
+ prioritizeNodeRequests(nodeIds) {
301
+ if (!this.lastCameraPosition || nodeIds.length <= 1) return nodeIds;
302
+ const [camLon, camLat] = this.lastCameraPosition;
303
+ const nodesWithPriority = nodeIds.map((nodeId) => {
304
+ const [depth, x, y] = nodeId.split("-").map(Number);
305
+ const nodeSize = 1 / 2 ** depth;
306
+ const nodeCenterX = x * nodeSize + nodeSize / 2;
307
+ const nodeCenterY = y * nodeSize + nodeSize / 2;
308
+ const dx = nodeCenterX - camLon;
309
+ const dy = nodeCenterY - camLat;
310
+ const distance = Math.sqrt(dx * dx + dy * dy);
311
+ return {
312
+ nodeId,
313
+ priority: depth * 10 + (distance > 0 ? 1e3 / distance : 1e3) * .1
314
+ };
315
+ });
316
+ nodesWithPriority.sort((a, b) => b.priority - a.priority);
317
+ return nodesWithPriority.map((n) => n.nodeId);
318
+ }
319
+ async onAdd(map, gl) {
320
+ this.map = map;
321
+ this.worker.postMessage({
322
+ type: "init",
323
+ url: this.url,
324
+ options: {
325
+ colorMode: this.options.colorMode,
326
+ maxCacheSize: this.options.maxCacheSize,
327
+ wasmPath: this.options.wasmPath
328
+ }
329
+ });
330
+ this.renderer = new THREE.WebGLRenderer({
331
+ canvas: map.getCanvas(),
332
+ context: gl
333
+ });
334
+ this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
335
+ this.renderer.autoClear = false;
336
+ if (this.options.enableEDL) this.setupEDL();
337
+ }
338
+ updateCacheConfig(config) {
339
+ Object.assign(this.options, config);
340
+ const protectedNodes = new Set(this.visibleNodes);
341
+ this.cacheManager.updateOptions({
342
+ maxNodes: this.options.maxCacheSize,
343
+ maxMemoryBytes: this.options.maxCacheMemory,
344
+ debug: this.options.debug
345
+ }, protectedNodes);
346
+ this.updateVisibleNodes();
347
+ }
348
+ setPointSize(size) {
349
+ this.options.pointSize = size;
350
+ this.rebuildAllMaterials();
351
+ this.map?.triggerRepaint();
352
+ }
353
+ setSseThreshold(threshold) {
354
+ this.options.sseThreshold = threshold;
355
+ this.updatePoints();
356
+ this.map?.triggerRepaint();
357
+ }
358
+ setDepthTest(enabled) {
359
+ this.options.depthTest = enabled;
360
+ this.rebuildAllMaterials();
361
+ this.map?.triggerRepaint();
362
+ }
363
+ /**
364
+ * Compute camera altitude in meters above sea level.
365
+ * MapLibre's getCameraAltitude() returns NaN in Globe mode because
366
+ * the Globe transform's internal _pixelPerMeter is never initialized.
367
+ * We replicate the formula using publicly accessible values.
368
+ */
369
+ computeCameraAltitude(fov, height, zoom) {
370
+ if (!this.map) return 0;
371
+ const latRad = this.map.getCenter().lat * DEG2RAD;
372
+ const fovRad = fov * DEG2RAD;
373
+ const pitchRad = this.map.getPitch() * DEG2RAD;
374
+ const pixelPerMeter = 512 * 2 ** zoom / (EARTH_CIRCUMFERENCE * Math.cos(latRad));
375
+ const cameraToCenterDistance = height / (2 * Math.tan(fovRad / 2));
376
+ return Math.cos(pitchRad) * cameraToCenterDistance / pixelPerMeter;
377
+ }
378
+ updatePoints() {
379
+ if (!this.map || !this.workerInitialized) return;
380
+ const now = performance.now();
381
+ if (now - this._lastUpdatePointsTime < 100) return;
382
+ this._lastUpdatePointsTime = now;
383
+ const fov = this.map.transform.fov;
384
+ const height = this.map.transform.height;
385
+ const zoom = this.map.getZoom();
386
+ const cameraLngLat = this.map.transform.getCameraLngLat().toArray();
387
+ const cameraAltitude = this.computeCameraAltitude(fov, height, zoom);
388
+ this.worker.postMessage({
389
+ type: "updatePoints",
390
+ cameraPosition: [...cameraLngLat, cameraAltitude],
391
+ mapHeight: height,
392
+ fov,
393
+ sseThreshold: this.options.sseThreshold,
394
+ zoom
395
+ });
396
+ }
397
+ render(_gl, options) {
398
+ if (!this.map || !this.renderer) return;
399
+ if (!this.sceneCenter || this.shouldUpdateSceneCenter()) {
400
+ const center = this.map.getCenter();
401
+ this.sceneCenter = maplibregl.MercatorCoordinate.fromLngLat(center);
402
+ this.clearCache();
403
+ }
404
+ const centerLngLat = this.sceneCenter.toLngLat();
405
+ const modelMatrix = this.map.transform.getMatrixForModel([centerLngLat.lng, centerLngLat.lat], 0);
406
+ const invS = 1 / this.sceneCenter.meterInMercatorCoordinateUnits();
407
+ this._tempMatrix1.fromArray(options.defaultProjectionData.mainMatrix);
408
+ this._tempMatrix2.fromArray(modelMatrix);
409
+ this._tempMatrix1.multiply(this._tempMatrix2);
410
+ this._tempMatrix2.set(invS, 0, 0, 0, 0, 0, EARTH_CIRCUMFERENCE, 0, 0, invS, 0, 0, 0, 0, 0, 1);
411
+ this._tempMatrix1.multiply(this._tempMatrix2);
412
+ this.camera.projectionMatrix.copy(this._tempMatrix1);
413
+ this.updatePoints();
414
+ this.renderer.setSize(this.map.getCanvas().width, this.map.getCanvas().height);
415
+ if (this.options.enableEDL) this.updateEDLSize();
416
+ this.renderer.resetState();
417
+ if (this.options.enableEDL && this.colorTarget && this.depthTarget && this.edlQuadScene && this.edlQuadCamera) {
418
+ this.renderer.setRenderTarget(this.depthTarget);
419
+ this.renderer.clear();
420
+ this.renderer.render(this.scene, this.camera);
421
+ this.renderer.setRenderTarget(this.colorTarget);
422
+ this.renderer.clear();
423
+ this.renderer.render(this.scene, this.camera);
424
+ this.renderer.setRenderTarget(null);
425
+ this.renderer.render(this.edlQuadScene, this.edlQuadCamera);
426
+ } else this.renderer.render(this.scene, this.camera);
427
+ this.map.triggerRepaint();
428
+ }
429
+ shouldUpdateSceneCenter() {
430
+ if (!this.sceneCenter || !this.map) return true;
431
+ const currentMercator = maplibregl.MercatorCoordinate.fromLngLat(this.map.getCenter());
432
+ const dx = currentMercator.x - this.sceneCenter.x;
433
+ const dy = currentMercator.y - this.sceneCenter.y;
434
+ return Math.sqrt(dx * dx + dy * dy) > .001;
435
+ }
436
+ onRemove(_map, _gl) {
437
+ this.worker.terminate();
438
+ this.cacheManager.clear();
439
+ this.visibleNodes.length = 0;
440
+ this.pendingRequests.clear();
441
+ this.requestQueue.length = 0;
442
+ this.colorTarget?.dispose();
443
+ this.colorTarget = void 0;
444
+ this.depthTarget?.dispose();
445
+ this.depthTarget = void 0;
446
+ this.edlMaterial?.dispose();
447
+ this.edlMaterial = void 0;
448
+ this.edlQuadScene?.clear();
449
+ this.edlQuadScene = void 0;
450
+ this.edlQuadCamera = void 0;
451
+ }
452
+ setupEDL() {
453
+ if (!this.renderer || !this.map) return;
454
+ const width = this.map.getCanvas().width;
455
+ const height = this.map.getCanvas().height;
456
+ this.colorTarget = new THREE.WebGLRenderTarget(width, height, {
457
+ minFilter: THREE.LinearFilter,
458
+ magFilter: THREE.LinearFilter,
459
+ format: THREE.RGBAFormat
460
+ });
461
+ this.depthTarget = new THREE.WebGLRenderTarget(width, height, {
462
+ minFilter: THREE.NearestFilter,
463
+ magFilter: THREE.NearestFilter,
464
+ format: THREE.RGBAFormat,
465
+ depthBuffer: true,
466
+ depthTexture: new THREE.DepthTexture(width, height)
467
+ });
468
+ this.edlMaterial = new THREE.ShaderMaterial({
469
+ uniforms: {
470
+ tColor: { value: this.colorTarget.texture },
471
+ tDepth: { value: this.depthTarget.depthTexture },
472
+ screenSize: { value: new THREE.Vector2(width, height) },
473
+ edlStrength: { value: this.options.edlStrength },
474
+ radius: { value: this.options.edlRadius }
475
+ },
476
+ vertexShader: edl_vert_default,
477
+ fragmentShader: edl_frag_default
478
+ });
479
+ this.edlQuadScene = new THREE.Scene();
480
+ this.edlQuadCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
481
+ const geometry = new THREE.PlaneGeometry(2, 2);
482
+ const quad = new THREE.Mesh(geometry, this.edlMaterial);
483
+ this.edlQuadScene.add(quad);
484
+ }
485
+ updateEDLSize() {
486
+ if (!this.map || !this.colorTarget || !this.depthTarget || !this.edlMaterial) return;
487
+ const width = this.map.getCanvas().width;
488
+ const height = this.map.getCanvas().height;
489
+ if (width === this._lastEdlWidth && height === this._lastEdlHeight) return;
490
+ this._lastEdlWidth = width;
491
+ this._lastEdlHeight = height;
492
+ this.colorTarget.setSize(width, height);
493
+ this.depthTarget.setSize(width, height);
494
+ this.edlMaterial.uniforms.screenSize.value.set(width, height);
495
+ }
496
+ createPointMaterial() {
497
+ if (this.options.enableEDL) return new THREE.ShaderMaterial({
498
+ uniforms: {
499
+ size: { value: this.options.pointSize },
500
+ scale: { value: window.devicePixelRatio },
501
+ useVertexColors: { value: this.options.colorMode !== "white" },
502
+ pointColor: { value: new THREE.Color(16777215) }
503
+ },
504
+ vertexShader: points_vert_default,
505
+ fragmentShader: points_frag_default,
506
+ vertexColors: this.options.colorMode !== "white",
507
+ depthTest: this.options.depthTest,
508
+ depthWrite: this.options.depthTest,
509
+ transparent: false
510
+ });
511
+ const material = new THREE.PointsMaterial({
512
+ vertexColors: this.options.colorMode !== "white",
513
+ size: this.options.pointSize,
514
+ depthTest: this.options.depthTest,
515
+ depthWrite: this.options.depthTest,
516
+ sizeAttenuation: true
517
+ });
518
+ if (this.options.colorMode === "white") material.color.setHex(16777215);
519
+ return material;
520
+ }
521
+ getPointSize() {
522
+ return this.options.pointSize;
523
+ }
524
+ getColorMode() {
525
+ return this.options.colorMode;
526
+ }
527
+ getSseThreshold() {
528
+ return this.options.sseThreshold;
529
+ }
530
+ getDepthTest() {
531
+ return this.options.depthTest;
532
+ }
533
+ getOptions() {
534
+ return { ...this.options };
535
+ }
536
+ isLoading() {
537
+ return this.pendingRequests.size > 0 || !this.workerInitialized;
538
+ }
539
+ getNodeStats() {
540
+ return {
541
+ loaded: this.cacheManager.size(),
542
+ visible: this.visibleNodes.length
543
+ };
544
+ }
545
+ clearCache() {
546
+ this.cacheManager.clear();
547
+ this.updateVisibleNodes();
548
+ }
549
+ setEDLEnabled(enabled) {
550
+ this.options.enableEDL = enabled;
551
+ if (enabled && !this.edlMaterial) this.setupEDL();
552
+ this.rebuildAllMaterials();
553
+ this.map?.triggerRepaint();
554
+ }
555
+ updateEDLParameters(params) {
556
+ if (!this.edlMaterial) return;
557
+ if (params.strength !== void 0) {
558
+ this.options.edlStrength = params.strength;
559
+ this.edlMaterial.uniforms.edlStrength.value = params.strength;
560
+ }
561
+ if (params.radius !== void 0) {
562
+ this.options.edlRadius = params.radius;
563
+ this.edlMaterial.uniforms.radius.value = params.radius;
564
+ }
565
+ this.map?.triggerRepaint();
566
+ }
567
+ getEDLParameters() {
568
+ return {
569
+ enabled: this.options.enableEDL,
570
+ strength: this.options.edlStrength,
571
+ radius: this.options.edlRadius
572
+ };
573
+ }
574
+ };
575
+ //#endregion
576
+ //#region src/globe-control.ts
577
+ /**
578
+ * A MapLibre control that toggles between Mercator and Globe projections.
579
+ */
580
+ var GlobeControl = class {
581
+ map;
582
+ container;
583
+ button;
584
+ isGlobe = false;
585
+ onAdd(map) {
586
+ this.map = map;
587
+ this.container = document.createElement("div");
588
+ this.container.classList.add("maplibregl-ctrl", "maplibregl-ctrl-group");
589
+ this.button = document.createElement("button");
590
+ this.button.type = "button";
591
+ this.button.title = "Toggle Globe view";
592
+ this.button.setAttribute("aria-label", "Toggle Globe view");
593
+ this.button.style.cssText = "display:flex;align-items:center;justify-content:center;";
594
+ this.updateIcon();
595
+ this.button.addEventListener("click", this.toggle);
596
+ this.container.appendChild(this.button);
597
+ return this.container;
598
+ }
599
+ onRemove() {
600
+ this.button?.removeEventListener("click", this.toggle);
601
+ this.container?.remove();
602
+ this.map = void 0;
603
+ this.container = void 0;
604
+ this.button = void 0;
605
+ }
606
+ getDefaultPosition() {
607
+ return "top-right";
608
+ }
609
+ toggle = () => {
610
+ if (!this.map) return;
611
+ this.isGlobe = !this.isGlobe;
612
+ const projection = this.isGlobe ? "globe" : "mercator";
613
+ this.map.setProjection({ type: projection });
614
+ this.updateIcon();
615
+ };
616
+ updateIcon() {
617
+ if (!this.button) return;
618
+ if (this.isGlobe) this.button.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="12" y1="3" x2="12" y2="21"/></svg>`;
619
+ else this.button.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>`;
620
+ }
621
+ };
622
+ //#endregion
623
+ //#region src/worker/sse.ts
624
+ function distance(a, b) {
625
+ const dx = a[0] - b[0];
626
+ const dy = a[1] - b[1];
627
+ const dz = a[2] - b[2];
628
+ return Math.sqrt(dx * dx + dy * dy + dz * dz);
629
+ }
630
+ /**
631
+ * Calculates Screen Space Error (SSE) for a point cloud node.
632
+ * Higher SSE = more visible error = should render with higher detail.
633
+ *
634
+ * @param maxDistance - Optional maximum effective distance. When the camera is
635
+ * extremely far away (e.g. Globe View at low zoom), the distance is capped
636
+ * to this value so that SSE does not approach 0.
637
+ */
638
+ function computeScreenSpaceError(cameraCenter, center, fov, geometricError, screenHeight, maxDistance) {
639
+ let dist = distance(cameraCenter, center);
640
+ if (maxDistance !== void 0 && dist > maxDistance) dist = maxDistance;
641
+ const fovRad = fov * (Math.PI / 180);
642
+ return geometricError * screenHeight / (2 * dist * Math.tan(fovRad / 2));
643
+ }
644
+ //#endregion
645
+ export { CacheManager, CopcLayer, GlobeControl, computeScreenSpaceError };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "maplibre-copc-layer",
3
+ "type": "module",
4
+ "version": "0.0.1",
5
+ "description": "A library for loading and rendering Cloud-Optimized Point Cloud (COPC) data in MapLibre GL JS using Three.js.",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./dist/index.mjs",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "src"
14
+ ],
15
+ "scripts": {
16
+ "build": "vp pack",
17
+ "build:demo": "vp build --outDir demo --base ./",
18
+ "dev": "vp dev",
19
+ "test": "vp test",
20
+ "typecheck": "tsc --noEmit",
21
+ "release": "bumpp",
22
+ "prepublishOnly": "pnpm run build"
23
+ },
24
+ "dependencies": {
25
+ "copc": "^0.0.8",
26
+ "proj4": "^2.15.0"
27
+ },
28
+ "peerDependencies": {
29
+ "maplibre-gl": ">=5.0.0",
30
+ "three": ">=0.150.0"
31
+ },
32
+ "devDependencies": {
33
+ "@cloudflare/kv-asset-handler": "^0.4.2",
34
+ "@types/node": "^25.5.0",
35
+ "@types/proj4": "^2.5.6",
36
+ "@types/three": "^0.173.0",
37
+ "@typescript/native-preview": "7.0.0-dev.20260316.1",
38
+ "bumpp": "^11.0.1",
39
+ "maplibre-gl": "^5.1.1",
40
+ "three": "^0.173.0",
41
+ "typescript": "^5.9.3",
42
+ "vite-plus": "latest",
43
+ "vitest": "npm:@voidzero-dev/vite-plus-test@latest"
44
+ },
45
+ "pnpm": {
46
+ "overrides": {
47
+ "vite": "npm:@voidzero-dev/vite-plus-core@latest",
48
+ "vitest": "npm:@voidzero-dev/vite-plus-test@latest"
49
+ }
50
+ },
51
+ "packageManager": "pnpm@10.32.1"
52
+ }