From 803fcea26a6dbcfb32e35bd2236b2c16ea26c01c Mon Sep 17 00:00:00 2001 From: Shunya Hara Date: Thu, 14 May 2026 19:11:34 +0900 Subject: [PATCH 01/51] =?UTF-8?q?=E3=83=90=E3=83=BC=E3=82=B8=E3=83=A7?= =?UTF-8?q?=E3=83=B3=E3=82=921.0.5=E3=81=AB=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extension/package-lock.json | 4 ++-- extension/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/extension/package-lock.json b/extension/package-lock.json index 03dfde8..71eefef 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "q3dviewer", - "version": "1.0.4", + "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "q3dviewer", - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "devDependencies": { "@types/node": "^22.19.18", diff --git a/extension/package.json b/extension/package.json index b7d97e7..fd0e1aa 100644 --- a/extension/package.json +++ b/extension/package.json @@ -2,7 +2,7 @@ "name": "q3dviewer", "displayName": "q3dviewer", "description": "Point Cloud Viewer for VS Code (PCD, PLY, LAS, LAZ, E57)", - "version": "1.0.4", + "version": "1.0.5", "publisher": "panasonic-ad", "icon": "media/at-logo-color-128x128.jpg", "license": "MIT", diff --git a/package-lock.json b/package-lock.json index ac87e1e..1945cd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "q3dweb", - "version": "1.0.4", + "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "q3dweb", - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "dependencies": { "laz-perf": "^0.0.7", diff --git a/package.json b/package.json index cfc5773..a288d7c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "q3dweb", "private": true, - "version": "1.0.4", + "version": "1.0.5", "type": "module", "license": "MIT", "scripts": { From a9703ab4ec1532a0cd1fd78be61a5ab7c41501ce Mon Sep 17 00:00:00 2001 From: Liu Yang Date: Mon, 18 May 2026 14:18:48 +0900 Subject: [PATCH 02/51] add realtime ros viewer (#5) * fix point select bug * make camera projection same as q3dviewer * realtime_viewer * 2 * 3 * better orig * n1 * re-organization * add setting to ros viewer * up ver to 1.0.6 --------- Co-authored-by: Liu.Yang Co-authored-by: Shunya Hara --- extension/package-lock.json | 18 +- extension/package.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/cloud_viewer.ts | 84 +++ src/film_maker_viewer.ts | 158 +++++ src/items/CloudItem.ts | 338 +++++++++- src/items/NativeCloudItem.ts | 79 +++ src/items/index.ts | 3 + src/main.ts | 66 +- src/parsers/e57Parser.ts | 65 ++ src/parsers/lasParser.ts | 271 ++++++++ src/parsers/pcdParser.ts | 324 +++++++++ src/parsers/plyParser.ts | 232 +++++++ src/realtime_viewer.ts | 315 +++++++++ src/utils/WebGLCloudBackend.ts | 298 ++++++++ src/utils/maths.ts | 15 + src/utils/pointCloud2Decode.ts | 156 +++++ src/utils/realtimeTypes.ts | 50 ++ src/viewer.ts | 897 ++++--------------------- src/viewer/cloudRenderer.ts | 78 +++ src/{ => viewer}/filmMaker.ts | 4 +- src/viewer/filmPlayback.ts | 162 +++++ src/viewer/measurement.ts | 95 +++ src/viewer/mouseControls.ts | 147 ++++ src/viewer/settingsUI.ts | 284 ++++++++ src/viewer/streamEngine.ts | 323 +++++++++ test/filmMaker.test.ts | 2 +- test/memoryCheck.qualification.test.ts | 10 +- test/viewer-edge.test.ts | 5 +- test/viewer-extra.test.ts | 5 +- test/viewer-finish.test.ts | 5 +- test/viewer-georef.test.ts | 5 +- test/viewer.test.ts | 52 +- 34 files changed, 3703 insertions(+), 851 deletions(-) create mode 100644 src/cloud_viewer.ts create mode 100644 src/film_maker_viewer.ts create mode 100644 src/items/NativeCloudItem.ts create mode 100644 src/parsers/e57Parser.ts create mode 100644 src/parsers/lasParser.ts create mode 100644 src/parsers/pcdParser.ts create mode 100644 src/parsers/plyParser.ts create mode 100644 src/realtime_viewer.ts create mode 100644 src/utils/WebGLCloudBackend.ts create mode 100644 src/utils/pointCloud2Decode.ts create mode 100644 src/utils/realtimeTypes.ts create mode 100644 src/viewer/cloudRenderer.ts rename src/{ => viewer}/filmMaker.ts (97%) create mode 100644 src/viewer/filmPlayback.ts create mode 100644 src/viewer/measurement.ts create mode 100644 src/viewer/mouseControls.ts create mode 100644 src/viewer/settingsUI.ts create mode 100644 src/viewer/streamEngine.ts diff --git a/extension/package-lock.json b/extension/package-lock.json index 71eefef..e2d954f 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "q3dviewer", - "version": "1.0.5", + "version": "1.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "q3dviewer", - "version": "1.0.5", + "version": "1.0.6", "license": "MIT", "devDependencies": { "@types/node": "^22.19.18", @@ -1804,8 +1804,8 @@ } }, "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.6.tgz", "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, "license": "MIT", @@ -2193,8 +2193,8 @@ } }, "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.6.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", @@ -2323,8 +2323,8 @@ "optional": true }, "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.6.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "dev": true, "license": "MIT", @@ -3156,7 +3156,7 @@ "dev": true, "license": "ISC", "dependencies": { - "reusify": "^1.0.4" + "reusify": "^1.0.6" } }, "node_modules/file-entry-cache": { diff --git a/extension/package.json b/extension/package.json index fd0e1aa..92cba92 100644 --- a/extension/package.json +++ b/extension/package.json @@ -2,7 +2,7 @@ "name": "q3dviewer", "displayName": "q3dviewer", "description": "Point Cloud Viewer for VS Code (PCD, PLY, LAS, LAZ, E57)", - "version": "1.0.5", + "version": "1.0.6", "publisher": "panasonic-ad", "icon": "media/at-logo-color-128x128.jpg", "license": "MIT", diff --git a/package-lock.json b/package-lock.json index 1945cd9..d1a8621 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "q3dweb", - "version": "1.0.5", + "version": "1.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "q3dweb", - "version": "1.0.5", + "version": "1.0.6", "license": "MIT", "dependencies": { "laz-perf": "^0.0.7", diff --git a/package.json b/package.json index a288d7c..5658ba4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "q3dweb", "private": true, - "version": "1.0.5", + "version": "1.0.6", "type": "module", "license": "MIT", "scripts": { diff --git a/src/cloud_viewer.ts b/src/cloud_viewer.ts new file mode 100644 index 0000000..00f28b9 --- /dev/null +++ b/src/cloud_viewer.ts @@ -0,0 +1,84 @@ +import { Viewer } from './viewer'; +import { + PCDHeader, getFieldSpec, readPackedRGB as _readPackedRGB, + parseAsciiPackedRGB as _parseAsciiPackedRGB, readNumericValue as _readNumericValue, +} from './parsers/pcdParser'; +import { LASStreamState } from './parsers/lasParser'; +import { + detectFormat, assembleChunkList as _assembleChunkList, + startStream as _startStream, processChunk as _processChunk, + finalizeStream as _finalizeStream, loadData as _loadData, + loadFile as _loadFile, handleDrop as _handleDrop, parseHeader as _parseHeader, + checkMemoryBudget as _checkMemoryBudget, +} from './viewer/streamEngine'; +import { + renderPoints as _renderPoints, resetRealtimeCloud as _resetRealtime, + appendRealtimePoints as _appendRealtime, +} from './viewer/cloudRenderer'; + +/** + * CloudViewer extends the base Viewer with point cloud file loading. + * Supports drag-and-drop and streaming for .pcd, .ply, .las, .laz, .e57 files. + */ +export class CloudViewer extends Viewer { + static readonly SUPPORTED_EXTENSIONS = ['.pcd', '.ply', '.las', '.laz', '.e57']; + skipMemoryCheck: boolean = false; + + // --- streaming state --- + currentFormat: ReturnType = 'pcd'; + streamFilename: string | undefined = undefined; + streamTotalSize: number = 0; + streamLoadedSize: number = 0; + streamAborted: boolean = false; + pcdHeader: PCDHeader | null = null; + lasStream: LASStreamState | null = null; + isBinary: boolean = false; + leftoverChunk: Uint8Array | null = null; + pointsLoaded: number = 0; + targetSampleRatio: number = 1; + fullBufferWriteOffset: number = 0; + chunkList: Uint8Array[] = []; + + // --- render buffers --- + MAX_POINTS_VISUAL = 15_000_000; + realtimeMaxPoints: number = 5_000_000; + posBuffer: Float32Array | null = null; + valBuffer: Float32Array | null = null; + rgbBuffer: Uint8Array | null = null; + fullBuffer: Uint8Array | null = null; + posIndex: number = 0; + dataMin: number = 0; + dataMax: number = 255; + + constructor(containerId: string) { + super(containerId); + this.setupDragDrop(); + } + + setupDragDrop() { + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(n => + this.container.addEventListener(n, (e: Event) => { e.preventDefault(); e.stopPropagation(); }, false)); + this.container.addEventListener('drop', (e) => void _handleDrop(this, e as DragEvent), false); + } + + startStream(totalSize: number, filename?: string): void { _startStream(this, totalSize, filename); } + processChunk(chunkData: Uint8Array, offset: number): void { _processChunk(this, chunkData, offset); } + parseHeader(headerStr: string): void { _parseHeader(this, headerStr); } + finalizeStream(): void { _finalizeStream(this); } + loadData(content: Uint8Array, filename?: string): void { _loadData(this, content, filename); } + async loadFile(file: File, append: boolean = false): Promise { return _loadFile(this, file, append); } + async handleDrop(e: DragEvent): Promise { await _handleDrop(this, e); } + readPackedRGB(view: DataView, byteOffset: number, type: string, size: number): number { return _readPackedRGB(view, byteOffset, type, size); } + parseAsciiPackedRGB(token: string, type: string, size: number): number { return _parseAsciiPackedRGB(token, type, size); } + readNumericValue(view: DataView, byteOffset: number, type: string, size: number): number { return _readNumericValue(view, byteOffset, type, size); } + getFieldSpec(fieldName: string) { return this.pcdHeader ? getFieldSpec(this.pcdHeader, fieldName) : null; } + detectFormat(filename?: string) { return detectFormat(filename); } + checkMemoryBudget(fileSize: number, format: string, filename?: string): boolean { return _checkMemoryBudget(this, fileSize, format, filename); } + assembleChunkList() { return _assembleChunkList(this); } + + renderPoints(positions: Float32Array, values: Float32Array, rgbColors?: Uint8Array): void { _renderPoints(this, positions, values, rgbColors); } + resetRealtimeCloud(): void { _resetRealtime(this); } + appendRealtimePoints(positions: Float32Array, values: Float32Array, rgbColors?: Uint8Array, maxPoints?: number, autoFitOnFirstChunk: boolean = false): void { + _appendRealtime(this, positions, values, rgbColors, maxPoints, autoFitOnFirstChunk); + } +} diff --git a/src/film_maker_viewer.ts b/src/film_maker_viewer.ts new file mode 100644 index 0000000..0179b6b --- /dev/null +++ b/src/film_maker_viewer.ts @@ -0,0 +1,158 @@ +import { CloudViewer } from './cloud_viewer'; +import { FilmMaker, KeyFrame } from './viewer/filmMaker'; +import { recoverCenterEuler } from './utils/maths'; +import { + buildFilmMakerSettings, refreshFilmMakerList, syncFilmMakerSpinboxes, FilmMakerUIRefs, +} from './viewer/settingsUI'; +import { + FilmPlaybackContext, + startPlayback as _startPlayback, stopPlayback as _stopPlayback, + tickFilmPlayback as _tickPlayback, startRecording as _startRecording, + stopRecording as _stopRecording, downloadLastRecording as _downloadLastRecording, +} from './viewer/filmPlayback'; + +/** + * FilmMakerViewer extends the base Viewer with keyframe-based film/flythrough recording. + * Its settings panel includes an item dropdown (inherited) plus a persistent film maker + * section (keyframe list, add/delete/play/record controls). + */ +export class FilmMakerViewer extends CloudViewer { + filmMaker: FilmMaker = new FilmMaker(); + /** Always true — enables Space/Delete keyboard shortcuts for keyframe editing. */ + get filmMakerTabActive(): boolean { return true; } + filmPlaybackIndex: number = 0; + filmPlaybackRequestId: number | null = null; + filmPlaybackLastTimestamp: number | null = null; + filmPlaybackAccumulatorMs: number = 0; + isPlayingFilm: boolean = false; + isRecordingFilm: boolean = false; + mediaRecorder: MediaRecorder | null = null; + recordedChunks: Blob[] = []; + lastRecordedBlob: Blob | null = null; + videoFileName: string = 'q3dweb.mp4'; + videoMimeType: string = 'video/mp4;codecs=h264'; + recordingVideoBitsPerSecond: number = 32_000_000; + recordingPixelRatioMin: number = 2; + private filmMakerListEl: HTMLElement | null = null; + private filmMakerPlayBtn: HTMLButtonElement | null = null; + private filmMakerSpinLin: HTMLInputElement | null = null; + private filmMakerSpinAng: HTMLInputElement | null = null; + private filmMakerSpinStop: HTMLInputElement | null = null; + + constructor(containerId: string) { + super(containerId); + // Fields are now initialized — install the film maker section in the panel. + this.installFilmMakerSection(); + } + + /** + * Inserts the film maker controls (keyframe list + add/delete/play buttons) into the + * settings panel between the item dropdown and the per-item content area. + * Called from the constructor after field initialization. + */ + private installFilmMakerSection(): void { + if (!this.settingsPanel || !this.settingsContent) return; + const section = document.createElement('div'); + section.style.cssText = 'margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid #444;'; + section.setAttribute('data-role', 'film-maker'); + const refs: FilmMakerUIRefs = buildFilmMakerSettings(section, { + filmMaker: this.filmMaker, + isPlayingFilm: this.isPlayingFilm, + isRecordingFilm: this.isRecordingFilm, + videoFileName: this.videoFileName, + videoMimeType: this.videoMimeType, + addKeyFrameFromCamera: () => this.addKeyFrameFromCamera(), + deleteCurrentKeyFrame: () => this.deleteCurrentKeyFrame(), + togglePlayback: () => this.togglePlayback(), + downloadLastRecording: () => this.downloadLastRecording(), + selectKeyFrame: (i: number) => { this.selectKeyFrame(i); this.refreshFilmMakerListUI(); }, + jumpToKeyFrame: (i: number) => this.jumpToKeyFrame(i), + refreshFilmMakerList: () => this.refreshFilmMakerListUI(), + setIsRecordingFilm: (v: boolean) => { this.isRecordingFilm = v; }, + setVideoFileName: (v: string) => { this.videoFileName = v; }, + setVideoMimeType: (v: string) => { this.videoMimeType = v; }, + setLinVel: (i: number, v: number) => this.filmMaker.setLinVel(i, v), + setAngVel: (i: number, v: number) => this.filmMaker.setAngVel(i, v), + setStopTime: (i: number, v: number) => this.filmMaker.setStopTime(i, v), + }); + this.filmMakerListEl = refs.listEl; + this.filmMakerPlayBtn = refs.playBtn; + this.filmMakerSpinLin = refs.spinLin; + this.filmMakerSpinAng = refs.spinAng; + this.filmMakerSpinStop = refs.spinStop; + this.setFilmMakerPlayButtonState(this.isPlayingFilm); + this.refreshFilmMakerListUI(); + syncFilmMakerSpinboxes(this.filmMaker, this.filmMakerSpinLin, this.filmMakerSpinAng, this.filmMakerSpinStop); + // Insert before the content area so it appears between the dropdown and item settings + this.settingsPanel.insertBefore(section, this.settingsContent); + } + + addKeyFrameFromCamera(): KeyFrame { + this.camera.updateMatrixWorld(); + const kf = this.filmMaker.addKeyFrame(this.camera.matrixWorld.clone()); + this.scene.add(kf.item); + this.refreshFilmMakerListUI(); + this.highlightSelectedKeyFrame(); + this.requestRender(); + return kf; + } + + deleteCurrentKeyFrame(): void { + const removed = this.filmMaker.deleteKeyFrame(this.filmMaker.currentIndex); + if (removed) { + this.scene.remove(removed.item); + this.refreshFilmMakerListUI(); + this.highlightSelectedKeyFrame(); + this.requestRender(); + } + } + + selectKeyFrame(index: number): void { + this.filmMaker.select(index); + this.highlightSelectedKeyFrame(); + syncFilmMakerSpinboxes(this.filmMaker, this.filmMakerSpinLin, this.filmMakerSpinAng, this.filmMakerSpinStop); + this.requestRender(); + } + + jumpToKeyFrame(index: number): void { + const kf = this.filmMaker.keyFrames[index]; + if (!kf) return; + const { center, euler } = recoverCenterEuler(kf.Twc, this.cameraDist); + this.cameraCenter.copy(center); + this.euler = [euler[0], euler[1], euler[2]]; + this.updateCamera(); + } + + private highlightSelectedKeyFrame(): void { + const sel = this.filmMaker.currentIndex; + this.filmMaker.keyFrames.forEach((kf: any, i: number) => { + kf.item.setColor(i === sel ? '#ff0000' : '#0000ff'); + kf.item.setLineWidth(i === sel ? 5 : 3); + }); + } + + private refreshFilmMakerListUI(): void { + if (!this.filmMakerListEl) return; + refreshFilmMakerList( + this.filmMakerListEl, this.filmMaker, + (i) => { this.selectKeyFrame(i); this.refreshFilmMakerListUI(); }, + (i) => this.jumpToKeyFrame(i), + ); + } + + setFilmMakerPlayButtonState(isPlaying: boolean): void { + if (!this.filmMakerPlayBtn) return; + this.filmMakerPlayBtn.textContent = isPlaying ? 'Playing' : 'Play'; + this.filmMakerPlayBtn.style.backgroundColor = isPlaying ? '#a33' : '#333'; + this.filmMakerPlayBtn.style.color = isPlaying ? '#fff' : '#eee'; + this.filmMakerPlayBtn.style.borderColor = isPlaying ? '#d66' : '#666'; + } + + togglePlayback(): void { this.isPlayingFilm ? this.stopPlayback() : this.startPlayback(); } + startPlayback(): boolean { return _startPlayback(this as unknown as FilmPlaybackContext); } + stopPlayback(): void { _stopPlayback(this as unknown as FilmPlaybackContext); } + tickFilmPlayback(timestamp?: number): void { _tickPlayback(this as unknown as FilmPlaybackContext, timestamp); } + startRecording(): void { _startRecording(this as unknown as FilmPlaybackContext); } + stopRecording(): void { _stopRecording(this as unknown as FilmPlaybackContext); } + downloadLastRecording(): boolean { return _downloadLastRecording(this as unknown as FilmPlaybackContext, (this as any).vscode); } +} diff --git a/src/items/CloudItem.ts b/src/items/CloudItem.ts index a283c26..dbbbbd0 100644 --- a/src/items/CloudItem.ts +++ b/src/items/CloudItem.ts @@ -33,36 +33,358 @@ export interface CloudItemOptions { } export class CloudItem extends THREE.Points { + private static readonly GROWTH_STEP_POINTS = 1_000_000; + private pointCount: number; + private lastAppendMeta: { + appendRequested: number; + appendActual: number; + dirtyPoints: number; + didDownsample: boolean; + resetToIncomingTailOnly: boolean; + totalPoints: number; + } | null = null; + constructor(positions: Float32Array, values: Float32Array, options: CloudItemOptions = {}, rgbColors?: Float32Array | Uint8Array) { + if (positions.length !== values.length * 3) { + throw new Error('positions length must be values length * 3'); + } + const geometry = new THREE.BufferGeometry(); - geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - geometry.setAttribute('value', new THREE.BufferAttribute(values, 1)); + geometry.setAttribute('position', CloudItem.makeDynamicAttribute(positions, 3)); + geometry.setAttribute('value', CloudItem.makeDynamicAttribute(values, 1)); if (rgbColors) { - const normalized = (rgbColors instanceof Uint8Array || rgbColors instanceof Uint8ClampedArray); - geometry.setAttribute('color', new THREE.BufferAttribute(rgbColors, 3, normalized)); + geometry.setAttribute('color', CloudItem.makeDynamicAttribute(CloudItem.toUint8Colors(rgbColors), 3, true)); options.colorMode = 'RGB'; } else { - geometry.setAttribute('color', new THREE.BufferAttribute(new Uint8Array(positions.length), 3, true)); + geometry.setAttribute('color', CloudItem.makeDynamicAttribute(new Uint8Array(positions.length), 3, true)); } + geometry.setDrawRange(0, values.length); const material = new CloudShaderMaterial(options); super(geometry, material); + this.pointCount = values.length; this.frustumCulled = false; // often necessary for custom shaders or dynamic bounds } + getPointCount(): number { + return this.pointCount; + } + + getLastAppendMeta(): { + appendRequested: number; + appendActual: number; + dirtyPoints: number; + didDownsample: boolean; + resetToIncomingTailOnly: boolean; + totalPoints: number; + } | null { + return this.lastAppendMeta; + } + + replacePoints(positions: Float32Array, values: Float32Array, rgbColors?: Float32Array | Uint8Array): void { + if (positions.length !== values.length * 3) { + throw new Error('positions length must be values length * 3'); + } + + const nextCount = values.length; + this.ensureCapacity(nextCount); + + const positionArray = this.getPositionArray(); + const valueArray = this.getValueArray(); + const colorArray = this.getColorArray(); + + positionArray.set(positions, 0); + valueArray.set(values, 0); + + if (rgbColors) { + colorArray.set(CloudItem.toUint8Colors(rgbColors), 0); + } else if (nextCount > 0) { + colorArray.fill(0, 0, nextCount * 3); + } + + this.pointCount = nextCount; + this.markAttributesDirtyRange(0, nextCount); + } + + appendPoints( + positions: Float32Array, + values: Float32Array, + rgbColors?: Float32Array | Uint8Array, + maxPoints?: number, + ): number { + if (positions.length !== values.length * 3) { + throw new Error('positions length must be values length * 3'); + } + + const appendCount = values.length; + if (appendCount === 0) return this.pointCount; + + const limit = maxPoints !== undefined && maxPoints > 0 + ? Math.max(1, Math.floor(maxPoints)) + : undefined; + + let appendPositions = positions; + let appendValues = values; + let incomingColors = rgbColors ? CloudItem.toUint8Colors(rgbColors) : null; + let appendActualCount = appendCount; + + // Like python CloudItem: if incoming chunk itself is too large, decimate first. + if (limit !== undefined) { + while (appendActualCount > Math.floor(limit * 0.9)) { + const half = CloudItem.downsampleChunkHalf(appendPositions, appendValues, incomingColors); + appendPositions = half.positions; + appendValues = half.values; + incomingColors = half.colors; + appendActualCount = appendValues.length; + if (appendActualCount <= 1) break; + } + } + + const targetCount = this.pointCount + appendActualCount; + this.ensureCapacityIncremental(targetCount, limit); + + let didDownsample = false; + let capacity = this.getCapacityPoints(); + while (this.pointCount > 1 && this.pointCount + appendActualCount > capacity) { + this.downsampleExistingHalfInPlace(); + didDownsample = true; + } + + const positionArray = this.getPositionArray(); + const valueArray = this.getValueArray(); + const colorArray = this.getColorArray(); + + let currentCount = this.pointCount; + + // If a single incoming chunk still exceeds capacity, keep its tail. + let resetToIncomingTailOnly = false; + capacity = this.getCapacityPoints(); + if (appendActualCount > capacity) { + const keepFromIncoming = capacity; + const srcOffsetPoints = appendActualCount - keepFromIncoming; + const srcOffsetPos = srcOffsetPoints * 3; + appendPositions = appendPositions.subarray(srcOffsetPos); + appendValues = appendValues.subarray(srcOffsetPoints); + incomingColors = incomingColors ? incomingColors.subarray(srcOffsetPos) : null; + appendActualCount = keepFromIncoming; + currentCount = 0; + resetToIncomingTailOnly = true; + } + + const dstOffsetPos = currentCount * 3; + positionArray.set(appendPositions, dstOffsetPos); + valueArray.set(appendValues, currentCount); + if (incomingColors) { + colorArray.set(incomingColors, dstOffsetPos); + } else { + colorArray.fill(0, dstOffsetPos, dstOffsetPos + appendActualCount * 3); + } + + this.pointCount = currentCount + appendActualCount; + + // Fast path: when we only append, upload only the appended tail. + // Slow path: when downsample/reset happened, upload the whole active range. + const dirtyStartPoint = (didDownsample || resetToIncomingTailOnly) ? 0 : currentCount; + const dirtyCountPoints = (didDownsample || resetToIncomingTailOnly) + ? this.pointCount + : appendActualCount; + this.markAttributesDirtyRange(dirtyStartPoint, dirtyCountPoints); + this.lastAppendMeta = { + appendRequested: appendCount, + appendActual: appendActualCount, + dirtyPoints: dirtyCountPoints, + didDownsample, + resetToIncomingTailOnly, + totalPoints: this.pointCount, + }; + return this.pointCount; + } + + private downsampleExistingHalfInPlace(): void { + if (this.pointCount <= 1) return; + + const positionArray = this.getPositionArray(); + const valueArray = this.getValueArray(); + const colorArray = this.getColorArray(); + + let writePoint = 0; + for (let readPoint = 0; readPoint < this.pointCount; readPoint += 2) { + const readPos = readPoint * 3; + const writePos = writePoint * 3; + + positionArray[writePos] = positionArray[readPos]; + positionArray[writePos + 1] = positionArray[readPos + 1]; + positionArray[writePos + 2] = positionArray[readPos + 2]; + + valueArray[writePoint] = valueArray[readPoint]; + + colorArray[writePos] = colorArray[readPos]; + colorArray[writePos + 1] = colorArray[readPos + 1]; + colorArray[writePos + 2] = colorArray[readPos + 2]; + writePoint++; + } + + this.pointCount = writePoint; + } + updateViewport(height: number) { const material = this.material as CloudShaderMaterial; material.uniforms.viewportHeight.value = Math.max(height, 1); } + + private ensureCapacity(requiredPoints: number): void { + const positionAttr = this.geometry.getAttribute('position') as THREE.BufferAttribute; + const currentCapacity = positionAttr.array.length / 3; + if (requiredPoints <= currentCapacity) return; + + const newCapacity = Math.max(requiredPoints, currentCapacity * 2, 1024); + const nextPos = new Float32Array(newCapacity * 3); + const nextVal = new Float32Array(newCapacity); + const nextColor = new Uint8Array(newCapacity * 3); + + const positionArray = this.getPositionArray(); + const valueArray = this.getValueArray(); + const colorArray = this.getColorArray(); + const copiedPosLen = this.pointCount * 3; + + if (copiedPosLen > 0) { + nextPos.set(positionArray.subarray(0, copiedPosLen), 0); + nextVal.set(valueArray.subarray(0, this.pointCount), 0); + nextColor.set(colorArray.subarray(0, copiedPosLen), 0); + } + + this.geometry.setAttribute('position', CloudItem.makeDynamicAttribute(nextPos, 3)); + this.geometry.setAttribute('value', CloudItem.makeDynamicAttribute(nextVal, 1)); + this.geometry.setAttribute('color', CloudItem.makeDynamicAttribute(nextColor, 3, true)); + } + + private ensureCapacityIncremental(requiredPoints: number, maxCapacity?: number): void { + const currentCapacity = this.getCapacityPoints(); + if (requiredPoints <= currentCapacity) return; + + let nextCapacity = Math.max(currentCapacity, CloudItem.GROWTH_STEP_POINTS); + while (requiredPoints > nextCapacity && (maxCapacity === undefined || nextCapacity < maxCapacity)) { + nextCapacity += CloudItem.GROWTH_STEP_POINTS; + } + if (maxCapacity !== undefined) { + nextCapacity = Math.min(nextCapacity, maxCapacity); + } + + if (nextCapacity > currentCapacity) { + this.ensureCapacity(nextCapacity); + } + } + + private markAttributesDirtyRange(startPoint: number, countPoints: number): void { + const positionAttr = this.geometry.getAttribute('position') as THREE.BufferAttribute; + const valueAttr = this.geometry.getAttribute('value') as THREE.BufferAttribute; + const colorAttr = this.geometry.getAttribute('color') as THREE.BufferAttribute; + + this.setUpdateRange(positionAttr, startPoint * 3, countPoints * 3); + this.setUpdateRange(valueAttr, startPoint, countPoints); + this.setUpdateRange(colorAttr, startPoint * 3, countPoints * 3); + + positionAttr.needsUpdate = true; + valueAttr.needsUpdate = true; + colorAttr.needsUpdate = true; + this.geometry.setDrawRange(0, this.pointCount); + this.geometry.boundingBox = null; + this.geometry.boundingSphere = null; + } + + private static makeDynamicAttribute( + array: Float32Array | Uint8Array, + itemSize: number, + normalized: boolean = false, + ): THREE.BufferAttribute { + const attr = new THREE.BufferAttribute(array, itemSize, normalized); + attr.setUsage(THREE.DynamicDrawUsage); + return attr; + } + + private setUpdateRange(attr: THREE.BufferAttribute, start: number, count: number): void { + if (count <= 0) return; + attr.clearUpdateRanges(); + attr.addUpdateRange(start, count); + } + + private getCapacityPoints(): number { + const positionAttr = this.geometry.getAttribute('position') as THREE.BufferAttribute; + return (positionAttr.array as Float32Array).length / 3; + } + + private getPositionArray(): Float32Array { + const attr = this.geometry.getAttribute('position') as THREE.BufferAttribute; + return attr.array as Float32Array; + } + + private getValueArray(): Float32Array { + const attr = this.geometry.getAttribute('value') as THREE.BufferAttribute; + return attr.array as Float32Array; + } + + private getColorArray(): Uint8Array { + const attr = this.geometry.getAttribute('color') as THREE.BufferAttribute; + return attr.array as Uint8Array; + } + + private static toUint8Colors(rgbColors: Float32Array | Uint8Array): Uint8Array { + if (rgbColors instanceof Uint8Array || rgbColors instanceof Uint8ClampedArray) { + return rgbColors; + } + + const out = new Uint8Array(rgbColors.length); + for (let i = 0; i < rgbColors.length; i++) { + const v = rgbColors[i]; + const scaled = v <= 1.0 ? v * 255 : v; + out[i] = Math.max(0, Math.min(255, Math.round(scaled))); + } + return out; + } + + private static downsampleChunkHalf( + positions: Float32Array, + values: Float32Array, + colors: Uint8Array | null, + ): { positions: Float32Array; values: Float32Array; colors: Uint8Array | null } { + const srcCount = values.length; + const dstCount = Math.ceil(srcCount / 2); + const dstPos = new Float32Array(dstCount * 3); + const dstVal = new Float32Array(dstCount); + const dstCol = colors ? new Uint8Array(dstCount * 3) : null; + + let write = 0; + for (let read = 0; read < srcCount; read += 2) { + const readPos = read * 3; + const writePos = write * 3; + dstPos[writePos] = positions[readPos]; + dstPos[writePos + 1] = positions[readPos + 1]; + dstPos[writePos + 2] = positions[readPos + 2]; + dstVal[write] = values[read]; + if (dstCol && colors) { + dstCol[writePos] = colors[readPos]; + dstCol[writePos + 1] = colors[readPos + 1]; + dstCol[writePos + 2] = colors[readPos + 2]; + } + write++; + } + + return { + positions: dstPos, + values: dstVal, + colors: dstCol, + }; + } } export class CloudShaderMaterial extends THREE.ShaderMaterial { constructor(options: CloudItemOptions) { + const alpha = options.alpha !== undefined ? options.alpha : 1.0; const uniforms = { pointSize: { value: options.size || 1.0 }, - alpha: { value: options.alpha !== undefined ? options.alpha : 1.0 }, + alpha: { value: alpha }, vmin: { value: 0.0 }, vmax: { value: 255.0 }, colorMode: { value: colorModeToUniformValue(options.colorMode) }, @@ -132,9 +454,9 @@ export class CloudShaderMaterial extends THREE.ShaderMaterial { uniforms: uniforms, vertexShader: vertexShader, fragmentShader: fragmentShader, - transparent: true, + transparent: alpha < 0.99, depthTest: true, - depthWrite: false, // usually better for transparent points + depthWrite: alpha >= 0.99, }); } } diff --git a/src/items/NativeCloudItem.ts b/src/items/NativeCloudItem.ts new file mode 100644 index 0000000..54f64b0 --- /dev/null +++ b/src/items/NativeCloudItem.ts @@ -0,0 +1,79 @@ +import * as THREE from 'three'; +import { WebGLCloudBackend } from '../utils/WebGLCloudBackend'; +import type { ColorMode } from '../utils/realtimeTypes'; + +export interface NativeCloudItemOptions { + colorMode?: ColorMode; + pointSize?: number; + alpha?: number; +} + +export class NativeCloudItem extends THREE.Object3D { + private readonly backend = new WebGLCloudBackend(); + private colorMode: ColorMode; + private pointSize: number; + private alpha: number; + private dataMin = Number.POSITIVE_INFINITY; + private dataMax = Number.NEGATIVE_INFINITY; + + constructor(options: NativeCloudItemOptions = {}) { + super(); + this.colorMode = options.colorMode ?? 'FLAT'; + this.pointSize = options.pointSize ?? 1; + this.alpha = options.alpha ?? 1; + } + + setColorMode(colorMode: ColorMode): void { + this.colorMode = colorMode; + } + + setPointSize(pointSize: number): void { + if (Number.isFinite(pointSize) && pointSize > 0) { + this.pointSize = pointSize; + } + } + + setAlpha(alpha: number): void { + if (!Number.isFinite(alpha)) return; + this.alpha = Math.max(0, Math.min(1, alpha)); + } + + reset(maxPoints: number): void { + this.backend.reset(maxPoints); + this.dataMin = Number.POSITIVE_INFINITY; + this.dataMax = Number.NEGATIVE_INFINITY; + } + + appendPoints( + renderer: THREE.WebGLRenderer, + positions: Float32Array, + values: Float32Array, + maxPoints: number, + ): void { + if (positions.length !== values.length * 3) return; + if (values.length === 0) return; + + this.backend.append(renderer, positions, values, maxPoints); + for (let i = 0; i < values.length; i++) { + const value = values[i]; + if (value < this.dataMin) this.dataMin = value; + if (value > this.dataMax) this.dataMax = value; + } + } + + draw(renderer: THREE.WebGLRenderer, camera: THREE.PerspectiveCamera): void { + this.backend.draw( + renderer, + camera, + this.colorMode, + Number.isFinite(this.dataMin) ? this.dataMin : 0, + Number.isFinite(this.dataMax) ? this.dataMax : 255, + this.pointSize, + this.alpha, + ); + } + + getPointCount(): number { + return this.backend.getPointCount(); + } +} diff --git a/src/items/index.ts b/src/items/index.ts index a543b34..82720e8 100644 --- a/src/items/index.ts +++ b/src/items/index.ts @@ -2,6 +2,9 @@ export { CloudItem, CloudShaderMaterial } from './CloudItem'; export type { CloudItemOptions } from './CloudItem'; +export { NativeCloudItem } from './NativeCloudItem'; +export type { NativeCloudItemOptions } from './NativeCloudItem'; + export { AxisItem } from './AxisItem'; export type { AxisItemOptions } from './AxisItem'; diff --git a/src/main.ts b/src/main.ts index 60b393a..3250579 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,53 @@ import './style.css' -import { Viewer } from './viewer'; +import { FilmMakerViewer } from './film_maker_viewer'; +import { RealtimeViewer } from './realtime_viewer'; +import { CloudViewer } from './cloud_viewer'; + +function toFloat32Array(data: unknown): Float32Array { + if (data instanceof Float32Array) return data; + if (data instanceof ArrayBuffer) return new Float32Array(data); + if (ArrayBuffer.isView(data)) { + const view = data as ArrayBufferView; + return new Float32Array(view.buffer, view.byteOffset, Math.floor(view.byteLength / Float32Array.BYTES_PER_ELEMENT)); + } + if (Array.isArray(data)) return new Float32Array(data); + return new Float32Array(0); +} + +function toUint8Array(data: unknown): Uint8Array { + if (data instanceof Uint8Array) return data; + if (data instanceof Uint8ClampedArray) return new Uint8Array(data); + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (ArrayBuffer.isView(data)) { + const view = data as ArrayBufferView; + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + } + if (Array.isArray(data)) return new Uint8Array(data); + return new Uint8Array(0); +} // Declare VS Code API declare function acquireVsCodeApi(): any; // Initialize Viewer try { - const viewer = new Viewer('app'); + const params = new URLSearchParams(window.location.search); + const mode = params.get('mode'); + const viewer: CloudViewer | RealtimeViewer = + mode === 'realtime' ? new RealtimeViewer('app') : + mode === 'film_maker' ? new FilmMakerViewer('app') : + new CloudViewer('app'); // default: no param or ?mode=cloud + + if (mode === 'realtime' && viewer instanceof RealtimeViewer) { + const rosWsUrl = params.get('ros'); + if (rosWsUrl) { + viewer.connectRosbridge(rosWsUrl); + console.log(`Realtime mode enabled, connecting to rosbridge: ${rosWsUrl}`); + } else { + console.log('Realtime mode enabled. Configure topics in the settings panel, or provide ?ros=ws://host:9090 to auto-connect.'); + } + } + // Expose viewer on window for E2E tests and debugging. (window as any).__viewer = viewer; console.log("q3dviewer Initialized."); @@ -25,21 +66,34 @@ try { (viewer as any).vscode = vscode; // VS Code Mode // Listen for messages from VS Code extension + const cv = viewer instanceof CloudViewer ? viewer : null; window.addEventListener('message', event => { const message = event.data; switch (message.type) { case 'loadData': case 'loadPCD': - viewer.loadData(message.value, message.filename); + cv?.loadData(message.value, message.filename); break; case 'startStream': - viewer.startStream(message.totalSize, message.filename); + cv?.startStream(message.totalSize, message.filename); break; case 'chunk': - viewer.processChunk(message.data, message.offset); + cv?.processChunk(message.data, message.offset); break; case 'endStream': - viewer.finalizeStream(); + cv?.finalizeStream(); + break; + case 'realtimePoints': { + const positions = toFloat32Array(message.positions); + const values = toFloat32Array(message.values); + const rgb = message.rgb !== undefined ? toUint8Array(message.rgb) : undefined; + const maxPoints = typeof message.maxPoints === 'number' ? message.maxPoints : undefined; + const autoFit = message.autoFitOnFirstChunk === true; + cv?.appendRealtimePoints(positions, values, rgb, maxPoints, autoFit); + break; + } + case 'realtimeReset': + cv?.resetRealtimeCloud(); break; } }); diff --git a/src/parsers/e57Parser.ts b/src/parsers/e57Parser.ts new file mode 100644 index 0000000..f7d4967 --- /dev/null +++ b/src/parsers/e57Parser.ts @@ -0,0 +1,65 @@ +/** + * E57 point-cloud parser (uses vendor WASM module). + * Extracted from viewer.ts for modularity. + */ + +import type { ParsedCloud } from './pcdParser'; +import { normalizeIntensity } from './lasParser'; + +/** Parse an E57 file. Recenters points around their mean for float32 stability. */ +export async function parseE57(data: Uint8Array, maxPoints: number): Promise { + console.log(`E57: parsing ${data.byteLength} bytes via vendor/e57-wasm...`); + const mod: any = await import('../../vendor/e57-wasm/pkg/e57_wasm.js'); + const wasmUrl = (await import('../../vendor/e57-wasm/pkg/e57_wasm_bg.wasm?url')).default; + await mod.default({ module_or_path: wasmUrl }); + + const pts = mod.parsePoints(data); + const src = pts.positions as Float32Array; + const colSrc = pts.colors as Float32Array; + const intenSrc = pts.intensities as Float32Array; + const totalPoints = pts.pointCount as number; + const hasColor = pts.hasColor as boolean; + const hasIntensity = pts.hasIntensity as boolean; + + const sampleRatio = totalPoints > maxPoints ? Math.ceil(totalPoints / maxPoints) : 1; + const estimated = Math.ceil(totalPoints / sampleRatio); + + const positions = new Float32Array(estimated * 3); + const intensity = new Float32Array(estimated); + const rgbColors = hasColor ? new Uint8Array(estimated * 3) : undefined; + + let sumX = 0, sumY = 0, sumZ = 0, n = 0; + for (let i = 0; i < totalPoints; i += sampleRatio) { + sumX += src[i * 3]; sumY += src[i * 3 + 1]; sumZ += src[i * 3 + 2]; + n++; + if (n >= estimated) break; + } + const cx = n > 0 ? sumX / n : 0; + const cy = n > 0 ? sumY / n : 0; + const cz = n > 0 ? sumZ / n : 0; + + let parsed = 0; + for (let i = 0; i < totalPoints; i += sampleRatio) { + if (parsed >= estimated) break; + const b = parsed * 3; + positions[b] = src[i * 3] - cx; + positions[b + 1] = src[i * 3 + 1] - cy; + positions[b + 2] = src[i * 3 + 2] - cz; + if (rgbColors) { + rgbColors[b] = Math.max(0, Math.min(255, Math.round(colSrc[i * 3] * 255))); + rgbColors[b + 1] = Math.max(0, Math.min(255, Math.round(colSrc[i * 3 + 1] * 255))); + rgbColors[b + 2] = Math.max(0, Math.min(255, Math.round(colSrc[i * 3 + 2] * 255))); + } + intensity[parsed] = hasIntensity ? intenSrc[i] * 255 : 0; + parsed++; + } + try { pts.free(); } catch { /* ignore */ } + + normalizeIntensity(intensity.subarray(0, parsed)); + console.log(`E57: loaded ${parsed}/${totalPoints} pts (ratio 1:${sampleRatio}), hasColor=${hasColor}, recentered at (${cx.toFixed(2)}, ${cy.toFixed(2)}, ${cz.toFixed(2)})`); + return { + positions: positions.subarray(0, parsed * 3), + values: intensity.subarray(0, parsed), + rgb: rgbColors?.subarray(0, parsed * 3), + }; +} diff --git a/src/parsers/lasParser.ts b/src/parsers/lasParser.ts new file mode 100644 index 0000000..40ee934 --- /dev/null +++ b/src/parsers/lasParser.ts @@ -0,0 +1,271 @@ +/** + * LAS / LAZ point-cloud parsers. + * Extracted from viewer.ts for modularity. + */ + +import { parseLASGeoInfo, readLASBounds } from '../utils/lasGeo'; +import { projToLatLon, registerWKT, convertByKey } from '../utils/projConvert'; +import type { ParsedCloud } from './pcdParser'; + +export interface LASBounds { + minX: number; maxX: number; + minY: number; maxY: number; + minZ: number; maxZ: number; +} + +export interface LASMetadata { + versionMajor: number; + versionMinor: number; + offsetToPointData: number; + pointDataRecordFormat: number; + pointDataRecordLength: number; + numberOfPoints: number; + xScale: number; yScale: number; zScale: number; + xOff: number; yOff: number; zOff: number; + hasRGB: boolean; + rgbOffset: number; + shiftX: number; + shiftY: number; + originLatLon: [number, number] | null; + bounds: LASBounds | null; +} + +export interface LASStreamState extends LASMetadata { + rawPointIndex: number; +} + +export interface ParsedLAS extends ParsedCloud { + originLatLon: [number, number] | null; + bounds: LASBounds | null; +} + +/** Normalize raw integer intensities to 0-255, matching q3dviewer behaviour. */ +export function normalizeIntensity(values: Float32Array): void { + let maxIntensity = 0; + for (let i = 0; i < values.length; i++) { + const v = Math.max(0, Math.trunc(values[i])); + values[i] = v; + if (v > maxIntensity) maxIntensity = v; + } + if (maxIntensity > 255) { + const scale = 255 / maxIntensity; + for (let i = 0; i < values.length; i++) values[i] = Math.trunc(values[i] * scale); + } +} + +/** Parse LAS header + georeference VLRs. Throws on invalid magic. */ +export function parseLASMetadata(data: Uint8Array): LASMetadata { + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + + const magic = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3)); + if (magic !== 'LASF') throw new Error('Not a valid LAS file'); + + const versionMajor = view.getUint8(24); + const versionMinor = view.getUint8(25); + const offsetToPointData = view.getUint32(96, true); + const pointDataRecordFormat = view.getUint8(104); + const pointDataRecordLength = view.getUint16(105, true); + + let numberOfPoints: number; + if (versionMajor === 1 && versionMinor >= 4) { + const legacyCount = view.getUint32(107, true); + const count64Low = view.getUint32(247, true); + numberOfPoints = count64Low > 0 ? count64Low : legacyCount; + } else { + numberOfPoints = view.getUint32(107, true); + } + + const xScale = view.getFloat64(131, true); + const yScale = view.getFloat64(139, true); + const zScale = view.getFloat64(147, true); + const xOff = view.getFloat64(155, true); + const yOff = view.getFloat64(163, true); + const zOff = view.getFloat64(171, true); + + const hasRGB = [2, 3, 5, 7, 8, 10].includes(pointDataRecordFormat); + let rgbOffset = -1; + if (hasRGB) { + switch (pointDataRecordFormat) { + case 2: rgbOffset = 20; break; + case 3: rgbOffset = 28; break; + case 5: rgbOffset = 28; break; + case 7: rgbOffset = 30; break; + case 8: rgbOffset = 30; break; + case 10: rgbOffset = 30; break; + } + } + + console.log(`LAS ${versionMajor}.${versionMinor}, Format ${pointDataRecordFormat}, ` + + `${numberOfPoints} points, Record Length ${pointDataRecordLength}`); + + const geo = parseLASGeoInfo(data); + const bounds = readLASBounds(data) as LASBounds | null; + let originLatLon: [number, number] | null = null; + let shiftX = 0; + let shiftY = 0; + if (geo && bounds) { + const cx = (bounds.minX + bounds.maxX) / 2; + const cy = (bounds.minY + bounds.maxY) / 2; + if (geo.epsg !== undefined) originLatLon = projToLatLon(geo.epsg, cx, cy); + if (!originLatLon && geo.wkt) { + const key = registerWKT(geo.wkt, '__LAS_WKT__'); + if (key) originLatLon = convertByKey(key, cx, cy); + } + if (originLatLon) { + shiftX = cx; shiftY = cy; + console.log(`LAS georef: EPSG=${geo.epsg ?? 'wkt'}, centre=(${originLatLon[0].toFixed(6)}, ${originLatLon[1].toFixed(6)})`); + } else if (geo.epsg !== undefined) { + console.warn(`LAS georef EPSG:${geo.epsg} not supported for overlay.`); + } + } + + return { + versionMajor, versionMinor, offsetToPointData, pointDataRecordFormat, + pointDataRecordLength, numberOfPoints, xScale, yScale, zScale, + xOff, yOff, zOff, hasRGB, rgbOffset, shiftX, shiftY, originLatLon, bounds, + }; +} + +/** Parse a fully-buffered LAS file. Samples to maxPoints. */ +export function parseLAS(data: Uint8Array, maxPoints: number): ParsedLAS { + const meta = parseLASMetadata(data); + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + + const sampleRatio = meta.numberOfPoints > maxPoints ? Math.ceil(meta.numberOfPoints / maxPoints) : 1; + const estimatedVisPoints = Math.ceil(meta.numberOfPoints / sampleRatio); + + const positions = new Float32Array(estimatedVisPoints * 3); + const intensities = new Float32Array(estimatedVisPoints); + const rgbColors = meta.hasRGB && meta.rgbOffset !== -1 ? new Uint8Array(estimatedVisPoints * 3) : null; + + let parsedPoints = 0; + for (let i = 0; i < meta.numberOfPoints; i += sampleRatio) { + if (parsedPoints >= estimatedVisPoints) break; + const recordStart = meta.offsetToPointData + i * meta.pointDataRecordLength; + if (recordStart + meta.pointDataRecordLength > data.byteLength) break; + + const rawX = view.getInt32(recordStart, true); + const rawY = view.getInt32(recordStart + 4, true); + const rawZ = view.getInt32(recordStart + 8, true); + const base = parsedPoints * 3; + positions[base] = rawX * meta.xScale + meta.xOff - meta.shiftX; + positions[base + 1] = rawY * meta.yScale + meta.yOff - meta.shiftY; + positions[base + 2] = rawZ * meta.zScale + meta.zOff; + intensities[parsedPoints] = view.getUint16(recordStart + 12, true); + + if (rgbColors && meta.rgbOffset !== -1) { + let r = view.getUint16(recordStart + meta.rgbOffset, true); + let g = view.getUint16(recordStart + meta.rgbOffset + 2, true); + let b = view.getUint16(recordStart + meta.rgbOffset + 4, true); + if (r > 255 || g > 255 || b > 255) { r = Math.floor(r / 256); g = Math.floor(g / 256); b = Math.floor(b / 256); } + rgbColors[base] = r; rgbColors[base + 1] = g; rgbColors[base + 2] = b; + } + parsedPoints++; + } + + normalizeIntensity(intensities.subarray(0, parsedPoints)); + return { + positions: positions.subarray(0, parsedPoints * 3), + values: intensities.subarray(0, parsedPoints), + rgb: rgbColors ? rgbColors.subarray(0, parsedPoints * 3) : undefined, + originLatLon: meta.originLatLon, + bounds: meta.bounds, + }; +} + +/** Decompress a LAZ file via laz-perf WASM, then reuse parseLAS. */ +export async function parseLAZ(data: Uint8Array, maxPoints: number): Promise { + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const magic = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3)); + if (magic !== 'LASF') throw new Error('Not a valid LAZ/LAS file'); + const offsetToPointData = view.getUint32(96, true); + const compressedFormatByte = view.getUint8(104); + const uncompressedFormat = compressedFormatByte & 0x3F; + + console.log(`LAZ: decompressing via laz-perf (format ${uncompressedFormat})...`); + const { createLazPerf } = await import('laz-perf/lib/web'); + const wasmUrl = (await import('laz-perf/lib/web/laz-perf.wasm?url')).default; + const LazPerf: any = await (createLazPerf as any)({ + locateFile: (path: string) => path.endsWith('.wasm') ? wasmUrl : path, + }); + const laszip = new LazPerf.LASZip(); + const dataPtr = LazPerf._malloc(data.byteLength); + LazPerf.HEAPU8.set(data, dataPtr); + try { + laszip.open(dataPtr, data.byteLength); + const count = laszip.getCount(); + const pointLen = laszip.getPointLength(); + console.log(`LAZ: ${count} points, pointLen=${pointLen}, fmt=${laszip.getPointFormat()}`); + + const synthetic = new Uint8Array(offsetToPointData + count * pointLen); + synthetic.set(data.subarray(0, offsetToPointData), 0); + synthetic[104] = uncompressedFormat; + new DataView(synthetic.buffer).setUint16(105, pointLen, true); + + const pointPtr = LazPerf._malloc(pointLen); + try { + let writeOff = offsetToPointData; + for (let i = 0; i < count; i++) { + laszip.getPoint(pointPtr); + synthetic.set(LazPerf.HEAPU8.subarray(pointPtr, pointPtr + pointLen), writeOff); + writeOff += pointLen; + } + } finally { LazPerf._free(pointPtr); } + + return parseLAS(synthetic, maxPoints); + } finally { + try { laszip.delete(); } catch { /* ignore */ } + LazPerf._free(dataPtr); + } +} + +/** Apply one chunk of raw LAS point records to the streaming buffers. */ +export function processLASRecords( + data: Uint8Array, + meta: LASStreamState, + posBuffer: Float32Array, + valBuffer: Float32Array, + rgbBuffer: Uint8Array | null, + posIndexRef: { value: number }, + targetSampleRatio: number, + leftoverRef: { value: Uint8Array | null }, +): void { + const rowSize = meta.pointDataRecordLength; + const count = Math.floor(data.byteLength / rowSize); + if (count === 0) { + leftoverRef.value = data.byteLength > 0 ? data.slice() : null; + return; + } + + const usableBytes = count * rowSize; + const view = new DataView(data.buffer, data.byteOffset, usableBytes); + + for (let i = 0; i < count; i++) { + const rawIdx = meta.rawPointIndex++; + if (rawIdx % targetSampleRatio !== 0) continue; + if (posIndexRef.value >= valBuffer.length) break; + + const recordStart = i * rowSize; + const rawX = view.getInt32(recordStart, true); + const rawY = view.getInt32(recordStart + 4, true); + const rawZ = view.getInt32(recordStart + 8, true); + const base = posIndexRef.value * 3; + + posBuffer[base] = rawX * meta.xScale + meta.xOff - meta.shiftX; + posBuffer[base + 1] = rawY * meta.yScale + meta.yOff - meta.shiftY; + posBuffer[base + 2] = rawZ * meta.zScale + meta.zOff; + valBuffer[posIndexRef.value] = view.getUint16(recordStart + 12, true); + + if (rgbBuffer && meta.rgbOffset !== -1) { + let r = view.getUint16(recordStart + meta.rgbOffset, true); + let g = view.getUint16(recordStart + meta.rgbOffset + 2, true); + let b = view.getUint16(recordStart + meta.rgbOffset + 4, true); + if (r > 255 || g > 255 || b > 255) { r = Math.floor(r / 256); g = Math.floor(g / 256); b = Math.floor(b / 256); } + rgbBuffer[base] = r; rgbBuffer[base + 1] = g; rgbBuffer[base + 2] = b; + } + posIndexRef.value++; + } + + const leftovers = data.byteLength - usableBytes; + leftoverRef.value = leftovers > 0 ? data.slice(usableBytes) : null; +} diff --git a/src/parsers/pcdParser.ts b/src/parsers/pcdParser.ts new file mode 100644 index 0000000..af2a596 --- /dev/null +++ b/src/parsers/pcdParser.ts @@ -0,0 +1,324 @@ +/** + * PCD (Point Cloud Data) parsing utilities. + * Extracted from viewer.ts for modularity. + */ + +export interface PCDHeader { + data: 'ascii' | 'binary' | 'binary_compressed'; + headerLen: number; + width: number; + height: number; + points: number; + rowSize: number; + offset: { [key: string]: number }; + fields?: string[]; + counts?: number[]; + types?: string[]; + sizes?: number[]; +} + +export interface ParsedCloud { + positions: Float32Array; + values: Float32Array; + rgb?: Uint8Array; +} + +/** Mutable state bag for streaming binary PCD parsing (one instance per stream). */ +export interface PCDBinaryState { + posBuffer: Float32Array; + valBuffer: Float32Array; + rgbBuffer: Uint8Array | null; + posIndex: number; + pointsLoaded: number; + targetSampleRatio: number; + leftoverChunk: Uint8Array | null; +} + +export function parsePCDHeader(headerStr: string): PCDHeader { + const pcdHeader: any = { offset: {} }; + const lines = headerStr.split('\n'); + + for (const rawLine of lines) { + const line = rawLine.trim(); + const words = line.split(/\s+/).filter(x => x); + if (words.length === 0) continue; + switch (words[0]) { + case 'WIDTH': pcdHeader.width = parseInt(words[1]); break; + case 'HEIGHT': pcdHeader.height = parseInt(words[1]); break; + case 'POINTS': pcdHeader.points = parseInt(words[1]); break; + case 'DATA': + pcdHeader.data = words[1]; + pcdHeader.headerLen = headerStr.length; + break; + } + } + + if (pcdHeader.points === undefined && pcdHeader.width !== undefined && pcdHeader.height !== undefined) { + pcdHeader.points = pcdHeader.width * pcdHeader.height; + } + + pcdHeader.rowSize = 16; + + const fieldsIdx = lines.findIndex(l => l.trimStart().startsWith('FIELDS')); + const sizeIdx = lines.findIndex(l => l.trimStart().startsWith('SIZE')); + const typeIdx = lines.findIndex(l => l.trimStart().startsWith('TYPE')); + const countIdx = lines.findIndex(l => l.trimStart().startsWith('COUNT')); + + if (fieldsIdx >= 0 && sizeIdx >= 0 && typeIdx >= 0) { + const fields = lines[fieldsIdx].trim().split(/\s+/).slice(1); + const sizes = lines[sizeIdx].trim().split(/\s+/).slice(1).map(Number); + const counts = countIdx >= 0 + ? lines[countIdx].trim().split(/\s+/).slice(1).map(Number) + : new Array(fields.length).fill(1); + const types = lines[typeIdx].trim().split(/\s+/).slice(1); + pcdHeader.fields = fields; + pcdHeader.counts = counts; + pcdHeader.types = types; + pcdHeader.sizes = sizes; + let size = 0; + pcdHeader.offset = {}; + for (let i = 0; i < fields.length; i++) { + pcdHeader.offset[fields[i]] = size; + size += sizes[i] * counts[i]; + } + pcdHeader.rowSize = size; + } + + return pcdHeader as PCDHeader; +} + +export function getFieldSpec( + header: PCDHeader, + fieldName: string, +): { offset: number; type: string; size: number; count: number } | null { + if (!header.fields || !header.types || !header.sizes || !header.counts) return null; + const idx = header.fields.indexOf(fieldName); + if (idx < 0) return null; + return { + offset: header.offset[fieldName], + type: header.types[idx], + size: header.sizes[idx], + count: header.counts[idx], + }; +} + +export function readNumericValue( + view: DataView, byteOffset: number, type: string, size: number, +): number { + if (type === 'F' && size === 4) return view.getFloat32(byteOffset, true); + if (type === 'F' && size === 8) return view.getFloat64(byteOffset, true); + if (type === 'U' && size === 1) return view.getUint8(byteOffset); + if (type === 'U' && size === 2) return view.getUint16(byteOffset, true); + if (type === 'U' && size === 4) return view.getUint32(byteOffset, true); + if (type === 'I' && size === 1) return view.getInt8(byteOffset); + if (type === 'I' && size === 2) return view.getInt16(byteOffset, true); + if (type === 'I' && size === 4) return view.getInt32(byteOffset, true); + if (size === 4) return view.getFloat32(byteOffset, true); + if (size === 2) return view.getUint16(byteOffset, true); + return view.getUint8(byteOffset); +} + +export function readPackedRGB( + view: DataView, byteOffset: number, type: string, size: number, +): number { + if (size === 4) return view.getUint32(byteOffset, true); + return (readNumericValue(view, byteOffset, type, size) >>> 0); +} + +export function getAsciiFieldTokenIndex(header: PCDHeader, fieldName: string): number | null { + if (!header.fields || !header.counts) return null; + let tokenIndex = 0; + for (let i = 0; i < header.fields.length; i++) { + if (header.fields[i] === fieldName) return tokenIndex; + tokenIndex += header.counts[i] ?? 1; + } + return null; +} + +export function parseAsciiNumericToken(token: string): number { + const value = Number(token); + return Number.isFinite(value) ? value : NaN; +} + +export function parseAsciiPackedRGB(token: string, type: string, size: number): number { + const value = parseAsciiNumericToken(token); + if (!Number.isFinite(value)) return 0; + if (type === 'F' && size === 4) { + const buffer = new ArrayBuffer(4); + const view = new DataView(buffer); + view.setFloat32(0, value, true); + return view.getUint32(0, true); + } + return (Math.max(0, Math.trunc(value)) >>> 0); +} + +/** Parse an ASCII PCD payload (entire file as Uint8Array). Returns positions/values/rgb. */ +export function parsePCDAscii( + data: Uint8Array, + header: PCDHeader, + maxPoints: number, +): ParsedCloud { + const text = new TextDecoder().decode(data.subarray(header.headerLen)); + const lines = text.split(/\r?\n/); + const totalPoints = header.points ?? lines.length; + const sampleRatio = totalPoints > maxPoints ? Math.ceil(totalPoints / maxPoints) : 1; + const estimated = Math.ceil(totalPoints / sampleRatio); + + const positions = new Float32Array(estimated * 3); + const values = new Float32Array(estimated); + + const intensitySpec = getFieldSpec(header, 'intensity'); + const intensityTokenIndex = getAsciiFieldTokenIndex(header, 'intensity'); + + let rgbSpec = getFieldSpec(header, 'rgb') ?? getFieldSpec(header, 'rgba'); + let rgbTokenIndex = getAsciiFieldTokenIndex(header, 'rgb') ?? getAsciiFieldTokenIndex(header, 'rgba'); + + const rgbColors = rgbSpec && rgbTokenIndex !== null ? new Uint8Array(estimated * 3) : null; + + const xIdx = getAsciiFieldTokenIndex(header, 'x'); + const yIdx = getAsciiFieldTokenIndex(header, 'y'); + const zIdx = getAsciiFieldTokenIndex(header, 'z'); + if (xIdx === null || yIdx === null || zIdx === null) { + throw new Error('ASCII PCD is missing x/y/z fields.'); + } + + let expectedTokenCount = 0; + if (header.fields && header.counts) { + for (let i = 0; i < header.fields.length; i++) expectedTokenCount += header.counts[i] ?? 1; + } + + let parsedPoints = 0; + for (let i = 0; i < lines.length; i += sampleRatio) { + if (parsedPoints >= estimated) break; + const line = lines[i].trim(); + if (!line || line.startsWith('#')) continue; + const tokens = line.split(/\s+/); + if (tokens.length < expectedTokenCount) continue; + const x = parseAsciiNumericToken(tokens[xIdx]); + const y = parseAsciiNumericToken(tokens[yIdx]); + const z = parseAsciiNumericToken(tokens[zIdx]); + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) continue; + const base = parsedPoints * 3; + positions[base] = x; positions[base + 1] = y; positions[base + 2] = z; + values[parsedPoints] = (intensitySpec && intensityTokenIndex !== null) + ? parseAsciiNumericToken(tokens[intensityTokenIndex]) : z; + if (rgbColors && rgbSpec && rgbTokenIndex !== null) { + const rgbInt = parseAsciiPackedRGB(tokens[rgbTokenIndex], rgbSpec.type, rgbSpec.size); + rgbColors[base] = (rgbInt >> 16) & 0xFF; + rgbColors[base + 1] = (rgbInt >> 8) & 0xFF; + rgbColors[base + 2] = rgbInt & 0xFF; + } + parsedPoints++; + } + + return { + positions: positions.subarray(0, parsedPoints * 3), + values: values.subarray(0, parsedPoints), + rgb: rgbColors ? rgbColors.subarray(0, parsedPoints * 3) : undefined, + }; +} + +/** Process one binary PCD chunk, appending to state.posBuffer etc. */ +export function processPCDBinaryChunk( + data: Uint8Array, + header: PCDHeader, + state: PCDBinaryState, +): void { + const rowSize = header.rowSize; + const totalBytes = data.byteLength; + const count = Math.floor(totalBytes / rowSize); + + const xOff = header.offset['x'] || 0; + const yOff = header.offset['y'] || 4; + const zOff = header.offset['z'] || 8; + + const intensitySpec = getFieldSpec(header, 'intensity'); + const valOff = intensitySpec ? intensitySpec.offset : -1; + + let rgbOff = header.offset['rgb'] ?? header.offset['rgba'] ?? -1; + let rgbSpec = getFieldSpec(header, 'rgb') ?? getFieldSpec(header, 'rgba'); + + const isStandardXYZ = (xOff === 0 && yOff === 4 && zOff === 8); + const isFloatAligned = (rowSize % 4 === 0); + const startIndex = (state.targetSampleRatio - (state.pointsLoaded % state.targetSampleRatio)) % state.targetSampleRatio; + + const dataView = new DataView(data.buffer, data.byteOffset, data.byteLength); + + if (isStandardXYZ && isFloatAligned) { + const floatsPerRow = rowSize / 4; + const floatCount = count * floatsPerRow; + + let floatView: Float32Array; + let uint32View: Uint32Array | null = null; + + if (data.byteOffset % 4 === 0 && data.byteLength % 4 === 0) { + floatView = new Float32Array(data.buffer, data.byteOffset, floatCount); + if (rgbOff !== -1) uint32View = new Uint32Array(data.buffer, data.byteOffset, floatCount); + } else { + const aligned = new Uint8Array(data); + floatView = new Float32Array(aligned.buffer, 0, floatCount); + if (rgbOff !== -1) uint32View = new Uint32Array(aligned.buffer, 0, floatCount); + } + + const valOffFloat = (valOff !== -1 && valOff % 4 === 0 && intensitySpec?.type === 'F' && intensitySpec?.size === 4) + ? (valOff / 4) : -1; + const rgbOffFloat = (rgbOff !== -1 && rgbOff % 4 === 0) ? (rgbOff / 4) : -1; + const doRGB = (state.rgbBuffer !== null && rgbOffFloat !== -1 && uint32View !== null); + + for (let i = startIndex; i < count; i += state.targetSampleRatio) { + if (state.posIndex >= state.posBuffer.length / 3) break; + const base = i * floatsPerRow; + state.posBuffer[state.posIndex * 3] = floatView[base]; + state.posBuffer[state.posIndex * 3 + 1] = floatView[base + 1]; + state.posBuffer[state.posIndex * 3 + 2] = floatView[base + 2]; + + if (valOffFloat !== -1) { + state.valBuffer[state.posIndex] = floatView[base + valOffFloat]; + } else if (intensitySpec && intensitySpec.count === 1) { + state.valBuffer[state.posIndex] = readNumericValue(dataView, i * rowSize + valOff, intensitySpec.type, intensitySpec.size); + } else { + state.valBuffer[state.posIndex] = floatView[base + 2]; + } + + if (doRGB) { + const rgbInt = uint32View![base + rgbOffFloat]; + state.rgbBuffer![state.posIndex * 3] = (rgbInt >> 16) & 0xFF; + state.rgbBuffer![state.posIndex * 3 + 1] = (rgbInt >> 8) & 0xFF; + state.rgbBuffer![state.posIndex * 3 + 2] = rgbInt & 0xFF; + } else if (state.rgbBuffer && rgbOff !== -1 && rgbSpec && rgbSpec.count === 1) { + const rgbInt = readPackedRGB(dataView, i * rowSize + rgbOff, rgbSpec.type, rgbSpec.size); + state.rgbBuffer[state.posIndex * 3] = (rgbInt >> 16) & 0xFF; + state.rgbBuffer[state.posIndex * 3 + 1] = (rgbInt >> 8) & 0xFF; + state.rgbBuffer[state.posIndex * 3 + 2] = rgbInt & 0xFF; + } + state.posIndex++; + } + } else { + for (let i = startIndex; i < count; i += state.targetSampleRatio) { + if (state.posIndex >= state.posBuffer.length / 3) break; + const base = i * rowSize; + const pIdx = state.posIndex * 3; + state.posBuffer[pIdx] = dataView.getFloat32(base + xOff, true); + state.posBuffer[pIdx + 1] = dataView.getFloat32(base + yOff, true); + state.posBuffer[pIdx + 2] = dataView.getFloat32(base + zOff, true); + + if (intensitySpec && intensitySpec.count === 1 && valOff !== -1) { + state.valBuffer[state.posIndex] = readNumericValue(dataView, base + valOff, intensitySpec.type, intensitySpec.size); + } else { + state.valBuffer[state.posIndex] = state.posBuffer[pIdx + 2]; + } + + if (state.rgbBuffer && rgbOff !== -1 && rgbSpec && rgbSpec.count === 1) { + const rgbInt = readPackedRGB(dataView, base + rgbOff, rgbSpec.type, rgbSpec.size); + state.rgbBuffer[pIdx] = (rgbInt >> 16) & 0xFF; + state.rgbBuffer[pIdx + 1] = (rgbInt >> 8) & 0xFF; + state.rgbBuffer[pIdx + 2] = rgbInt & 0xFF; + } + state.posIndex++; + } + } + + state.pointsLoaded += count; + const leftovers = totalBytes - (count * rowSize); + state.leftoverChunk = leftovers > 0 ? data.slice(count * rowSize) : null; +} diff --git a/src/parsers/plyParser.ts b/src/parsers/plyParser.ts new file mode 100644 index 0000000..62895a0 --- /dev/null +++ b/src/parsers/plyParser.ts @@ -0,0 +1,232 @@ +/** + * PLY point-cloud parser. + * Extracted from viewer.ts for modularity. + */ + +import type { ParsedCloud } from './pcdParser'; + +function plyTypeSize(type: string): number { + switch (type) { + case 'char': case 'int8': + case 'uchar': case 'uint8': return 1; + case 'short': case 'int16': + case 'ushort': case 'uint16': return 2; + case 'int': case 'int32': case 'float': case 'float32': + case 'uint': case 'uint32': return 4; + case 'double': case 'float64': return 8; + default: return 4; + } +} + +function readPLYValue(view: DataView, offset: number, type: string, isLE: boolean): number { + switch (type) { + case 'char': case 'int8': return view.getInt8(offset); + case 'uchar': case 'uint8': return view.getUint8(offset); + case 'short': case 'int16': return view.getInt16(offset, isLE); + case 'ushort': case 'uint16':return view.getUint16(offset, isLE); + case 'int': case 'int32': return view.getInt32(offset, isLE); + case 'uint': case 'uint32': return view.getUint32(offset, isLE); + case 'float': case 'float32':return view.getFloat32(offset, isLE); + case 'double': case 'float64':return view.getFloat64(offset, isLE); + default: return view.getFloat32(offset, isLE); + } +} + +function decodePLYPackedRGB(value: number, type: string): number { + if (type === 'float' || type === 'float32') { + const buf = new ArrayBuffer(4); + const dv = new DataView(buf); + dv.setFloat32(0, value, true); + return dv.getUint32(0, true); + } + return (Math.max(0, Math.trunc(value)) >>> 0); +} + +/** Parse a PLY file (entire Uint8Array). Returns positions/values/rgb. */ +export function parsePLY(data: Uint8Array, maxPoints: number): ParsedCloud { + const headerRegion = new TextDecoder().decode(data.subarray(0, Math.min(data.byteLength, 100000))); + const endHeaderIdx = headerRegion.indexOf('end_header'); + if (endHeaderIdx === -1) throw new Error('Invalid PLY file: missing end_header'); + const nlIdx = headerRegion.indexOf('\n', endHeaderIdx); + if (nlIdx === -1) throw new Error('Invalid PLY file: malformed end_header'); + const dataStartByte = nlIdx + 1; + + const headerStr = headerRegion.substring(0, endHeaderIdx); + const lines = headerStr.split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('comment')); + + if (lines[0] !== 'ply') throw new Error('Not a PLY file'); + + let format = 'ascii'; + let vertexCount = 0; + interface PLYProp { name: string; type: string; } + const vertexProps: PLYProp[] = []; + let currentElement = ''; + + for (const line of lines) { + const parts = line.split(/\s+/); + if (parts[0] === 'format') { + format = parts[1]; + } else if (parts[0] === 'element') { + currentElement = parts[1]; + if (currentElement === 'vertex') vertexCount = parseInt(parts[2]); + } else if (parts[0] === 'property' && currentElement === 'vertex') { + if (parts[1] === 'list') continue; + vertexProps.push({ name: parts[2], type: parts[1] }); + } + } + + console.log(`PLY: format=${format}, vertices=${vertexCount}, props=${vertexProps.map(p => p.name).join(',')}`); + + const propIndex: { [key: string]: number } = {}; + vertexProps.forEach((p, i) => { propIndex[p.name] = i; }); + + if (!('x' in propIndex) || !('y' in propIndex) || !('z' in propIndex)) { + throw new Error('PLY missing x/y/z properties'); + } + + const hasRed = 'red' in propIndex && 'green' in propIndex && 'blue' in propIndex; + const hasPackedRGB = 'rgb' in propIndex; + const hasIntensity = 'intensity' in propIndex || 'scalar_intensity' in propIndex + || 'scalar_Intensity' in propIndex || 'reflectance' in propIndex; + const intensityName = 'intensity' in propIndex ? 'intensity' + : 'scalar_intensity' in propIndex ? 'scalar_intensity' + : 'scalar_Intensity' in propIndex ? 'scalar_Intensity' + : 'reflectance'; + + const sampleRatio = vertexCount > maxPoints ? Math.ceil(vertexCount / maxPoints) : 1; + const estimatedVisPoints = Math.ceil(vertexCount / sampleRatio); + + const positions = new Float32Array(estimatedVisPoints * 3); + const values = new Float32Array(estimatedVisPoints); + const rgbColors = (hasRed || hasPackedRGB) ? new Uint8Array(estimatedVisPoints * 3) : null; + + let parsedPoints = 0; + + if (format === 'ascii') { + const bytes = data; + const total = bytes.byteLength; + const LF = 0x0A; + let lineStart = dataStartByte; + let vertexIndex = 0; + let intensityIsFloat = false; + let maxIntensityRaw = 0; + + const xIdx = propIndex['x']; + const yIdx = propIndex['y']; + const zIdx = propIndex['z']; + const iIdx = hasIntensity ? propIndex[intensityName] : -1; + const rIdx = hasRed ? propIndex['red'] : -1; + const gIdx = hasRed ? propIndex['green'] : -1; + const bIdx = hasRed ? propIndex['blue'] : -1; + const rgbPackedIdx = hasPackedRGB ? propIndex['rgb'] : -1; + const rgbPackedType = hasPackedRGB ? vertexProps[propIndex['rgb']].type : ''; + + const decoder = new TextDecoder(); + + const processLine = (lineBytes: Uint8Array) => { + let end = lineBytes.byteLength; + if (end > 0 && lineBytes[end - 1] === 0x0D) end--; + if (end === 0) return; + const lineStr = decoder.decode(lineBytes.subarray(0, end)); + if (!lineStr.trim()) return; + const tokens = lineStr.split(/\s+/); + const offset = tokens[0] === '' ? 1 : 0; + if (tokens.length - offset < vertexProps.length) return; + + if (vertexIndex % sampleRatio === 0 && parsedPoints < estimatedVisPoints) { + const base = parsedPoints * 3; + const x = parseFloat(tokens[xIdx + offset]); + const y = parseFloat(tokens[yIdx + offset]); + const z = parseFloat(tokens[zIdx + offset]); + if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) { + positions[base] = x; positions[base + 1] = y; positions[base + 2] = z; + if (iIdx >= 0) { + const v = parseFloat(tokens[iIdx + offset]); + if (Number.isFinite(v)) { + values[parsedPoints] = v; + if (!Number.isInteger(v)) intensityIsFloat = true; + const av = Math.abs(v); + if (av > maxIntensityRaw) maxIntensityRaw = av; + } + } else { values[parsedPoints] = z; } + + if (rgbColors) { + if (hasRed) { + rgbColors[base] = parseInt(tokens[rIdx + offset]); + rgbColors[base + 1] = parseInt(tokens[gIdx + offset]); + rgbColors[base + 2] = parseInt(tokens[bIdx + offset]); + } else if (hasPackedRGB) { + const rgbInt = decodePLYPackedRGB(parseFloat(tokens[rgbPackedIdx + offset]), rgbPackedType); + rgbColors[base] = (rgbInt >> 16) & 0xFF; + rgbColors[base + 1] = (rgbInt >> 8) & 0xFF; + rgbColors[base + 2] = rgbInt & 0xFF; + } + } + parsedPoints++; + } + } + vertexIndex++; + }; + + for (let i = dataStartByte; i < total && vertexIndex < vertexCount; i++) { + if (bytes[i] === LF) { + processLine(bytes.subarray(lineStart, i)); + lineStart = i + 1; + } + } + if (lineStart < total && vertexIndex < vertexCount) { + processLine(bytes.subarray(lineStart, total)); + } + + console.log(`PLY ASCII parsed ${parsedPoints} pts, intensityIsFloat=${intensityIsFloat}, maxRaw=${maxIntensityRaw}`); + + if (hasIntensity && intensityIsFloat && maxIntensityRaw > 0 && maxIntensityRaw <= 1.0) { + for (let i = 0; i < parsedPoints; i++) values[i] = Math.round(values[i] * 255); + } + } else { + const isLE = format === 'binary_little_endian'; + const view = new DataView(data.buffer, data.byteOffset + dataStartByte, data.byteLength - dataStartByte); + + let vertexByteSize = 0; + const propOffsets: number[] = []; + for (const prop of vertexProps) { + propOffsets.push(vertexByteSize); + vertexByteSize += plyTypeSize(prop.type); + } + + for (let i = 0; i < vertexCount; i += sampleRatio) { + if (parsedPoints >= estimatedVisPoints) break; + const rowOffset = i * vertexByteSize; + if (rowOffset + vertexByteSize > view.byteLength) break; + const base = parsedPoints * 3; + positions[base] = readPLYValue(view, rowOffset + propOffsets[propIndex['x']], vertexProps[propIndex['x']].type, isLE); + positions[base + 1] = readPLYValue(view, rowOffset + propOffsets[propIndex['y']], vertexProps[propIndex['y']].type, isLE); + positions[base + 2] = readPLYValue(view, rowOffset + propOffsets[propIndex['z']], vertexProps[propIndex['z']].type, isLE); + + values[parsedPoints] = (hasIntensity && intensityName in propIndex) + ? readPLYValue(view, rowOffset + propOffsets[propIndex[intensityName]], vertexProps[propIndex[intensityName]].type, isLE) + : positions[base + 2]; + + if (rgbColors) { + if (hasRed) { + rgbColors[base] = readPLYValue(view, rowOffset + propOffsets[propIndex['red']], vertexProps[propIndex['red']].type, isLE); + rgbColors[base + 1] = readPLYValue(view, rowOffset + propOffsets[propIndex['green']], vertexProps[propIndex['green']].type, isLE); + rgbColors[base + 2] = readPLYValue(view, rowOffset + propOffsets[propIndex['blue']], vertexProps[propIndex['blue']].type, isLE); + } else if (hasPackedRGB) { + const floatVal = readPLYValue(view, rowOffset + propOffsets[propIndex['rgb']], vertexProps[propIndex['rgb']].type, isLE); + const rgbInt = decodePLYPackedRGB(floatVal, vertexProps[propIndex['rgb']].type); + rgbColors[base] = (rgbInt >> 16) & 0xFF; + rgbColors[base + 1] = (rgbInt >> 8) & 0xFF; + rgbColors[base + 2] = rgbInt & 0xFF; + } + } + parsedPoints++; + } + } + + return { + positions: positions.subarray(0, parsedPoints * 3), + values: values.subarray(0, parsedPoints), + rgb: rgbColors ? rgbColors.subarray(0, parsedPoints * 3) : undefined, + }; +} diff --git a/src/realtime_viewer.ts b/src/realtime_viewer.ts new file mode 100644 index 0000000..ebbeac1 --- /dev/null +++ b/src/realtime_viewer.ts @@ -0,0 +1,315 @@ +import * as THREE from 'three'; +import { Viewer } from './viewer'; +import { CloudItem } from './items/CloudItem'; +import { AxisItem } from './items/AxisItem'; +import { NativeCloudItem } from './items/NativeCloudItem'; +import { decodePointCloud2, inferColorModeFromFields } from './utils/pointCloud2Decode'; +import { makeLabel, makeTextInput, makeNumberInput, makeButton } from './viewer/settingsUI'; +import type { + ColorMode, + DecodedCloudChunk, + OdomJson, + PointCloud2Json, + RealtimeTopicOptions, + RosbridgePublishMessage, +} from './utils/realtimeTypes'; + +/** + * RealtimeViewer extends Viewer with ROS PointCloud2 realtime ingestion. + * It is optimized for streaming append workloads instead of one-shot file loads. + */ +export class RealtimeViewer extends Viewer { + private rosSocket: WebSocket | null = null; + private cloudTopicName: string = '/cloud_registered'; + private odomTopicName: string = '/odometry'; + private maxPointsPerScan: number = 1500; + private mapColorMode: ColorMode | null = null; + private readonly maxQueuedChunks = 4; + private readonly maxApplyChunksPerCommit = 4; + private readonly mapUpdateIntervalMs = 80; + private lastMapUpdateTs = 0; + private readonly scanUpdateIntervalMs = 120; + private lastScanUpdateTs = 0; + private pendingChunks: DecodedCloudChunk[] = []; + private pendingScanChunk: DecodedCloudChunk | null = null; + private readonly mapItemName = 'map'; + private readonly scanItemName = 'scan'; + private readonly odomItemName = 'odom'; + realtimeMaxPoints: number = 5_000_000; + + constructor(containerId: string) { + super(containerId); + this.setupRealtimeItems(); + this.installRealtimeSection(); + } + + /** Inserts the realtime connection panel above the item settings area. */ + private installRealtimeSection(): void { + if (!this.settingsPanel || !this.settingsContent) return; + const section = document.createElement('div'); + section.style.cssText = 'margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid #444;'; + section.setAttribute('data-role', 'realtime'); + + section.appendChild(makeLabel('ROS Bridge URL')); + const rosInput = makeTextInput('ws://localhost:9090', () => {}); + + section.appendChild(rosInput); + section.appendChild(makeLabel('Cloud Topic')); + const cloudTopicInput = makeTextInput(this.cloudTopicName, v => { this.cloudTopicName = v; }); + section.appendChild(cloudTopicInput); + + section.appendChild(makeLabel('Odom Topic')); + const odomTopicInput = makeTextInput(this.odomTopicName, v => { this.odomTopicName = v; }); + section.appendChild(odomTopicInput); + + section.appendChild(makeLabel('Max Points / Scan')); + const maxScanInput = makeNumberInput(this.maxPointsPerScan, 1, 1_000_000, 100, v => { this.maxPointsPerScan = Math.floor(v); }); + section.appendChild(maxScanInput); + + section.appendChild(makeLabel('Max Accumulated Points')); + const maxCloudInput = makeNumberInput(this.realtimeMaxPoints, 10_000, 50_000_000, 100_000, v => { this.realtimeMaxPoints = Math.floor(v); }); + section.appendChild(maxCloudInput); + + const connectBtn = makeButton('Connect', () => { + const url = rosInput.value.trim(); + if (!url) return; + this.connectRosbridge(url); + connectBtn.textContent = 'Reconnect'; + }); + section.appendChild(connectBtn); + + this.settingsPanel.insertBefore(section, this.settingsContent); + } + + setRealtimeOptions(options: RealtimeTopicOptions): void { + const cloudTopic = options.cloudTopicName ?? options.topicName; + if (cloudTopic) this.cloudTopicName = cloudTopic; + if (options.odomTopicName) this.odomTopicName = options.odomTopicName; + if (typeof options.maxPointsPerScan === 'number' && options.maxPointsPerScan > 0) { + this.maxPointsPerScan = Math.floor(options.maxPointsPerScan); + } + if (typeof options.maxAccumulatedPoints === 'number' && options.maxAccumulatedPoints > 0) { + this.realtimeMaxPoints = Math.floor(options.maxAccumulatedPoints); + } + } + + connectRosbridge(wsUrl: string, options: RealtimeTopicOptions = {}): void { + this.setRealtimeOptions(options); + this.mapColorMode = null; + this.pendingChunks = []; + this.pendingScanChunk = null; + this.lastMapUpdateTs = 0; + this.lastScanUpdateTs = 0; + this.setupRealtimeItems(); + const map = this.items[this.mapItemName]; + if (map instanceof NativeCloudItem) { + map.reset(this.realtimeMaxPoints); + map.setColorMode('FLAT'); + } + + if (this.rosSocket && this.rosSocket.readyState <= WebSocket.OPEN) { + this.disconnectRosbridge(); + } + + const socket = new WebSocket(wsUrl); + this.rosSocket = socket; + + socket.addEventListener('open', () => { + const cloudSubscribe = { + op: 'subscribe', + topic: this.cloudTopicName, + type: 'sensor_msgs/PointCloud2', + queue_length: 1, + throttle_rate: 0, + }; + const odomSubscribe = { + op: 'subscribe', + topic: this.odomTopicName, + type: 'nav_msgs/Odometry', + queue_length: 1, + throttle_rate: 0, + }; + socket.send(JSON.stringify(cloudSubscribe)); + socket.send(JSON.stringify(odomSubscribe)); + }); + + socket.addEventListener('message', (event: MessageEvent) => { + this.onRosbridgeMessage(event.data, options); + }); + + socket.addEventListener('close', () => { + if (this.rosSocket === socket) this.rosSocket = null; + }); + + socket.addEventListener('error', (err) => { + console.error('rosbridge websocket error:', err); + }); + } + + disconnectRosbridge(): void { + if (!this.rosSocket) return; + + if (this.rosSocket.readyState === WebSocket.OPEN) { + this.rosSocket.send(JSON.stringify({ op: 'unsubscribe', topic: this.cloudTopicName })); + this.rosSocket.send(JSON.stringify({ op: 'unsubscribe', topic: this.odomTopicName })); + } + this.rosSocket.close(); + this.rosSocket = null; + } + + ingestPointCloud2(pointCloud2: PointCloud2Json, options: RealtimeTopicOptions = {}): void { + if (this.mapColorMode === null) { + this.mapColorMode = inferColorModeFromFields(pointCloud2.fields); + const map = this.items[this.mapItemName]; + if (map instanceof NativeCloudItem) { + map.setColorMode(this.mapColorMode); + } + } + + const decoded = decodePointCloud2( + pointCloud2, + options.maxPointsPerScan ?? this.maxPointsPerScan, + this.mapColorMode, + ); + if (!decoded) return; + + const maxAccumulatedPoints = options.maxAccumulatedPoints ?? this.realtimeMaxPoints; + const chunk: DecodedCloudChunk = { + positions: decoded.positions, + values: decoded.values, + rgb: decoded.rgb, + maxAccumulatedPoints, + }; + + if (this.pendingChunks.length >= this.maxQueuedChunks) { + this.pendingChunks.shift(); + } + this.pendingChunks.push(chunk); + this.pendingScanChunk = chunk; + + this.requestRender(); + } + + override render(): void { + this.applyPendingCloudUpdates(); + super.render(); + const map = this.items[this.mapItemName]; + if (map instanceof NativeCloudItem) { + map.draw(this.renderer, this.camera); + } + + if (this.pendingChunks.length > 0 || this.pendingScanChunk) { + this.requestRender(); + } + } + + private onRosbridgeMessage(rawData: string, options: RealtimeTopicOptions): void { + let payload: RosbridgePublishMessage; + try { + payload = JSON.parse(rawData) as RosbridgePublishMessage; + } catch { + return; + } + + if (payload.op !== 'publish' || !payload.topic || !payload.msg) { + return; + } + + if (payload.topic === this.cloudTopicName) { + const pointCloud2 = payload.msg as PointCloud2Json; + this.ingestPointCloud2(pointCloud2, options); + return; + } + + if (payload.topic === this.odomTopicName) { + this.ingestOdometry(payload.msg as OdomJson); + } + } + + private setupRealtimeItems(): void { + if (!(this.items[this.mapItemName] instanceof NativeCloudItem)) { + const map = new NativeCloudItem({ + colorMode: 'FLAT', + pointSize: 1, + alpha: 1, + }); + this.addItem(this.mapItemName, map); + } + + if (!(this.items[this.scanItemName] instanceof CloudItem)) { + const scan = new CloudItem( + new Float32Array(0), + new Float32Array(0), + { + size: 2, + alpha: 1, + colorMode: 'FLAT', + color: '#ffffff', + }, + ); + this.addItem(this.scanItemName, scan); + } + + if (!(this.items[this.odomItemName] instanceof AxisItem)) { + const odom = new AxisItem({ size: 0.5, width: 5 }); + this.addItem(this.odomItemName, odom); + } + } + + private ingestOdometry(msg: OdomJson): void { + const odom = this.items[this.odomItemName]; + if (!(odom instanceof AxisItem)) return; + + const p = msg?.pose?.pose?.position; + const q = msg?.pose?.pose?.orientation; + if (!p || !q) return; + + const t = new THREE.Vector3(p.x ?? 0, p.y ?? 0, p.z ?? 0); + const quat = new THREE.Quaternion(q.x ?? 0, q.y ?? 0, q.z ?? 0, q.w ?? 1).normalize(); + const matrix = new THREE.Matrix4().compose(t, quat, new THREE.Vector3(1, 1, 1)); + odom.setTransform(matrix); + this.requestRender(); + } + + private applyPendingCloudUpdates(): void { + if (this.pendingChunks.length === 0 && !this.pendingScanChunk) return; + + this.setupRealtimeItems(); + const map = this.items[this.mapItemName] as NativeCloudItem | undefined; + const scan = this.items[this.scanItemName] as CloudItem | undefined; + if (!(map instanceof NativeCloudItem) || !(scan instanceof CloudItem)) return; + + let lastScanCount = 0; + const chunkToScan = this.pendingScanChunk; + const now = performance.now(); + const shouldUpdateScan = !!chunkToScan && + (now - this.lastScanUpdateTs >= this.scanUpdateIntervalMs || this.pendingChunks.length === 0); + if (chunkToScan && shouldUpdateScan) { + scan.replacePoints(chunkToScan.positions, chunkToScan.values, undefined); + lastScanCount = chunkToScan.values.length; + this.pendingScanChunk = null; + this.lastScanUpdateTs = now; + } + + let applied = 0; + const shouldCommitMap = this.pendingChunks.length > 0 && + (now - this.lastMapUpdateTs >= this.mapUpdateIntervalMs || this.pendingChunks.length >= this.maxQueuedChunks); + + if (shouldCommitMap) { + while (applied < this.maxApplyChunksPerCommit && this.pendingChunks.length > 0) { + const chunk = this.pendingChunks.shift(); + if (!chunk) break; + + map.appendPoints(this.renderer, chunk.positions, chunk.values, chunk.maxAccumulatedPoints); + applied++; + lastScanCount = chunk.values.length; + } + this.lastMapUpdateTs = now; + } + + if (applied > 0 && this.statusElement) { + this.statusElement.textContent = + `Map: ${map.getPointCount().toLocaleString()} pts | Scan: ${lastScanCount.toLocaleString()} pts`; + } + } +} diff --git a/src/utils/WebGLCloudBackend.ts b/src/utils/WebGLCloudBackend.ts new file mode 100644 index 0000000..5c6a657 --- /dev/null +++ b/src/utils/WebGLCloudBackend.ts @@ -0,0 +1,298 @@ +import * as THREE from 'three'; +import type { ColorMode } from './realtimeTypes'; + +export class WebGLCloudBackend { + private static readonly CAPACITY_STEP = 1_000_000; + private gl: WebGLRenderingContext | null = null; + private program: WebGLProgram | null = null; + private vbo: WebGLBuffer | null = null; + private aPosition = -1; + private aValue = -1; + private uView: WebGLUniformLocation | null = null; + private uProj: WebGLUniformLocation | null = null; + private uVMin: WebGLUniformLocation | null = null; + private uVMax: WebGLUniformLocation | null = null; + private uPointSize: WebGLUniformLocation | null = null; + private uColorMode: WebGLUniformLocation | null = null; + private uFlatColor: WebGLUniformLocation | null = null; + private uAlpha: WebGLUniformLocation | null = null; + private cpuBuffer = new Float32Array(0); + private capacityPoints = 0; + private pointCount = 0; + private maxPoints = 5_000_000; + + reset(maxPoints: number): void { + this.maxPoints = Math.max(1, Math.floor(maxPoints)); + this.pointCount = 0; + } + + getPointCount(): number { + return this.pointCount; + } + + append( + renderer: THREE.WebGLRenderer, + positions: Float32Array, + values: Float32Array, + maxPoints: number, + ): void { + if (positions.length !== values.length * 3) return; + if (values.length === 0) return; + + this.maxPoints = Math.max(1, Math.floor(maxPoints)); + this.ensureInitialized(renderer); + if (!this.gl || !this.vbo) return; + + let appendPos = positions; + let appendVal = values; + + while (appendVal.length > Math.floor(this.maxPoints * 0.9) && appendVal.length > 1) { + const halfCount = Math.ceil(appendVal.length / 2); + const nextPos = new Float32Array(halfCount * 3); + const nextVal = new Float32Array(halfCount); + let w = 0; + for (let r = 0; r < appendVal.length; r += 2) { + const rp = r * 3; + const wp = w * 3; + nextPos[wp] = appendPos[rp]; + nextPos[wp + 1] = appendPos[rp + 1]; + nextPos[wp + 2] = appendPos[rp + 2]; + nextVal[w] = appendVal[r]; + w++; + } + appendPos = nextPos; + appendVal = nextVal; + } + + this.ensureCapacity(Math.min(this.maxPoints, this.pointCount + appendVal.length)); + if (!this.vbo) return; + + let didDownsample = false; + while (this.pointCount > 1 && this.pointCount + appendVal.length > this.capacityPoints) { + this.downsampleExistingHalfInPlace(); + didDownsample = true; + } + + let currentCount = this.pointCount; + if (appendVal.length > this.capacityPoints) { + const keep = this.capacityPoints; + const srcStart = appendVal.length - keep; + appendVal = appendVal.subarray(srcStart); + appendPos = appendPos.subarray(srcStart * 3); + currentCount = 0; + didDownsample = true; + } + + for (let i = 0; i < appendVal.length; i++) { + const srcPos = i * 3; + const dstPoint = currentCount + i; + const dst = dstPoint * 4; + this.cpuBuffer[dst] = appendPos[srcPos]; + this.cpuBuffer[dst + 1] = appendPos[srcPos + 1]; + this.cpuBuffer[dst + 2] = appendPos[srcPos + 2]; + this.cpuBuffer[dst + 3] = appendVal[i]; + } + + this.pointCount = currentCount + appendVal.length; + + const gl = this.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo); + if (didDownsample || currentCount === 0) { + const uploaded = this.cpuBuffer.subarray(0, this.pointCount * 4); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, uploaded); + } else { + const dstStart = currentCount * 4; + const uploaded = this.cpuBuffer.subarray(dstStart, (currentCount + appendVal.length) * 4); + gl.bufferSubData(gl.ARRAY_BUFFER, currentCount * 16, uploaded); + } + gl.bindBuffer(gl.ARRAY_BUFFER, null); + } + + draw( + renderer: THREE.WebGLRenderer, + camera: THREE.PerspectiveCamera, + colorMode: ColorMode, + dataMin: number, + dataMax: number, + pointSize: number, + alpha: number, + ): void { + if (this.pointCount <= 0) return; + if (colorMode === 'RGB') return; + + this.ensureInitialized(renderer); + if (!this.gl || !this.program || !this.vbo) return; + + const gl = this.gl; + const view = camera.matrixWorldInverse.elements; + const proj = camera.projectionMatrix.elements; + + renderer.resetState(); + + gl.enable(gl.DEPTH_TEST); + if (alpha < 0.99) { + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.depthMask(false); + } else { + gl.disable(gl.BLEND); + gl.depthMask(true); + } + + gl.useProgram(this.program); + gl.uniformMatrix4fv(this.uView, false, view as unknown as Float32List); + gl.uniformMatrix4fv(this.uProj, false, proj as unknown as Float32List); + gl.uniform1f(this.uVMin, dataMin); + gl.uniform1f(this.uVMax, dataMax); + gl.uniform1f(this.uPointSize, pointSize); + gl.uniform1f(this.uColorMode, colorMode === 'FLAT' ? 2 : 0); + gl.uniform3f(this.uFlatColor, 1, 1, 1); + gl.uniform1f(this.uAlpha, alpha); + + gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo); + gl.enableVertexAttribArray(this.aPosition); + gl.enableVertexAttribArray(this.aValue); + gl.vertexAttribPointer(this.aPosition, 3, gl.FLOAT, false, 16, 0); + gl.vertexAttribPointer(this.aValue, 1, gl.FLOAT, false, 16, 12); + gl.drawArrays(gl.POINTS, 0, this.pointCount); + gl.disableVertexAttribArray(this.aPosition); + gl.disableVertexAttribArray(this.aValue); + gl.bindBuffer(gl.ARRAY_BUFFER, null); + gl.useProgram(null); + gl.depthMask(true); + + renderer.resetState(); + } + + private ensureInitialized(renderer: THREE.WebGLRenderer): void { + if (this.gl && this.program && this.vbo) return; + + this.gl = renderer.getContext(); + const gl = this.gl; + + if (!this.program) { + const vertSrc = ` + attribute vec3 aPosition; + attribute float aValue; + uniform mat4 uView; + uniform mat4 uProj; + uniform float uVMin; + uniform float uVMax; + uniform float uPointSize; + uniform float uColorMode; + uniform vec3 uFlatColor; + varying vec3 vColor; + + vec3 rainbow(float x) { + float t = clamp((x - uVMin) / max(uVMax - uVMin, 1e-6), 0.0, 1.0); + float h = t * 0.6666; + vec3 K = vec3(1.0, 2.0 / 3.0, 1.0 / 3.0); + vec3 p = abs(fract(vec3(h) + K) * 6.0 - vec3(3.0)); + return clamp(p - vec3(1.0), 0.0, 1.0); + } + + void main() { + vec3 c = rainbow(aValue); + if (uColorMode > 1.5) c = uFlatColor; + vColor = c; + vec4 mv = uView * vec4(aPosition, 1.0); + gl_Position = uProj * mv; + gl_PointSize = uPointSize; + } + `; + const fragSrc = ` + precision mediump float; + varying vec3 vColor; + uniform float uAlpha; + void main() { + gl_FragColor = vec4(vColor, uAlpha); + } + `; + + const vert = this.compileShader(gl, gl.VERTEX_SHADER, vertSrc); + const frag = this.compileShader(gl, gl.FRAGMENT_SHADER, fragSrc); + const program = gl.createProgram(); + if (!vert || !frag || !program) return; + gl.attachShader(program, vert); + gl.attachShader(program, frag); + gl.linkProgram(program); + gl.deleteShader(vert); + gl.deleteShader(frag); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + gl.deleteProgram(program); + return; + } + this.program = program; + this.aPosition = gl.getAttribLocation(program, 'aPosition'); + this.aValue = gl.getAttribLocation(program, 'aValue'); + this.uView = gl.getUniformLocation(program, 'uView'); + this.uProj = gl.getUniformLocation(program, 'uProj'); + this.uVMin = gl.getUniformLocation(program, 'uVMin'); + this.uVMax = gl.getUniformLocation(program, 'uVMax'); + this.uPointSize = gl.getUniformLocation(program, 'uPointSize'); + this.uColorMode = gl.getUniformLocation(program, 'uColorMode'); + this.uFlatColor = gl.getUniformLocation(program, 'uFlatColor'); + this.uAlpha = gl.getUniformLocation(program, 'uAlpha'); + } + + if (!this.vbo) { + this.vbo = gl.createBuffer(); + } + + this.ensureCapacity(Math.min(this.maxPoints, WebGLCloudBackend.CAPACITY_STEP)); + } + + private ensureCapacity(requiredPoints: number): void { + if (!this.gl || !this.vbo) return; + if (requiredPoints <= this.capacityPoints) return; + + let next = Math.max(this.capacityPoints, WebGLCloudBackend.CAPACITY_STEP); + while (next < requiredPoints && next < this.maxPoints) { + next += WebGLCloudBackend.CAPACITY_STEP; + } + next = Math.min(next, this.maxPoints); + if (next <= this.capacityPoints) return; + + const nextCpu = new Float32Array(next * 4); + if (this.pointCount > 0 && this.cpuBuffer.length > 0) { + nextCpu.set(this.cpuBuffer.subarray(0, this.pointCount * 4), 0); + } + this.cpuBuffer = nextCpu; + this.capacityPoints = next; + + const gl = this.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo); + gl.bufferData(gl.ARRAY_BUFFER, this.capacityPoints * 16, gl.DYNAMIC_DRAW); + if (this.pointCount > 0) { + gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.cpuBuffer.subarray(0, this.pointCount * 4)); + } + gl.bindBuffer(gl.ARRAY_BUFFER, null); + } + + private downsampleExistingHalfInPlace(): void { + if (this.pointCount <= 1) return; + let write = 0; + for (let read = 0; read < this.pointCount; read += 2) { + const src = read * 4; + const dst = write * 4; + this.cpuBuffer[dst] = this.cpuBuffer[src]; + this.cpuBuffer[dst + 1] = this.cpuBuffer[src + 1]; + this.cpuBuffer[dst + 2] = this.cpuBuffer[src + 2]; + this.cpuBuffer[dst + 3] = this.cpuBuffer[src + 3]; + write++; + } + this.pointCount = write; + } + + private compileShader(gl: WebGLRenderingContext, type: number, src: string): WebGLShader | null { + const shader = gl.createShader(type); + if (!shader) return null; + gl.shaderSource(shader, src); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + gl.deleteShader(shader); + return null; + } + return shader; + } +} diff --git a/src/utils/maths.ts b/src/utils/maths.ts index 0c97df7..19dbb87 100644 --- a/src/utils/maths.ts +++ b/src/utils/maths.ts @@ -120,6 +120,21 @@ export function logSO3(R: THREE.Matrix3): THREE.Vector3 { return v.multiplyScalar(magnitude); } +/** Euler (roll, pitch, yaw) → 4x4 rotation matrix (same convention as eulerToMatrix3 but returns Matrix4). */ +export function eulerToMatrix4(roll: number, pitch: number, yaw: number): THREE.Matrix4 { + const cx = Math.cos(roll), sx = Math.sin(roll); + const cy = Math.cos(pitch), sy = Math.sin(pitch); + const cz = Math.cos(yaw), sz = Math.sin(yaw); + const m = new THREE.Matrix4(); + m.set( + cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx, 0, + sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx, 0, + -sy, cy * sx, cy * cx, 0, + 0, 0, 0, 1, + ); + return m; +} + /** Euler (roll, pitch, yaw) → 3x3 rotation matrix Rz(yaw) * Ry(pitch) * Rx(roll). */ export function eulerToMatrix3(roll: number, pitch: number, yaw: number): THREE.Matrix3 { const cx = Math.cos(roll), sx = Math.sin(roll); diff --git a/src/utils/pointCloud2Decode.ts b/src/utils/pointCloud2Decode.ts new file mode 100644 index 0000000..1f65a37 --- /dev/null +++ b/src/utils/pointCloud2Decode.ts @@ -0,0 +1,156 @@ +import { ColorMode, PointCloud2Json, PointFieldJson } from './realtimeTypes'; + +const PF_INT8 = 1; +const PF_UINT8 = 2; +const PF_INT16 = 3; +const PF_UINT16 = 4; +const PF_INT32 = 5; +const PF_UINT32 = 6; +const PF_FLOAT32 = 7; +const PF_FLOAT64 = 8; + +export function decodePointCloud2( + msg: PointCloud2Json, + maxPointsPerScan: number, + colorMode: ColorMode, +): { + positions: Float32Array; + values: Float32Array; + rgb?: Uint8Array; +} | null { + if (!msg || !Array.isArray(msg.fields) || msg.fields.length === 0 || !msg.data) { + return null; + } + + const pointCountRaw = Math.max(msg.width * msg.height, 0); + if (pointCountRaw === 0 || msg.point_step <= 0) return null; + + const rawBytes = decodeBase64(msg.data); + const availableCount = Math.floor(rawBytes.byteLength / msg.point_step); + const pointCount = Math.min(pointCountRaw, availableCount); + if (pointCount <= 0) return null; + + const sampleRatio = maxPointsPerScan > 0 && pointCount > maxPointsPerScan + ? Math.ceil(pointCount / maxPointsPerScan) + : 1; + const sampledCount = Math.ceil(pointCount / sampleRatio); + + const fieldMap = new Map(); + for (const field of msg.fields) fieldMap.set(field.name, field); + + const fx = fieldMap.get('x'); + const fy = fieldMap.get('y'); + const fz = fieldMap.get('z'); + if (!fx || !fy || !fz) return null; + + const fi = fieldMap.get('intensity'); + const frgbPacked = colorMode === 'RGB' ? (fieldMap.get('rgb') ?? fieldMap.get('rgba')) : undefined; + const fr = colorMode === 'RGB' ? (fieldMap.get('r') ?? fieldMap.get('red')) : undefined; + const fg = colorMode === 'RGB' ? (fieldMap.get('g') ?? fieldMap.get('green')) : undefined; + const fb = colorMode === 'RGB' ? (fieldMap.get('b') ?? fieldMap.get('blue')) : undefined; + + const positions = new Float32Array(sampledCount * 3); + const values = new Float32Array(sampledCount); + const hasRgb = colorMode === 'RGB'; + const rgb = hasRgb ? new Uint8Array(sampledCount * 3) : undefined; + + const littleEndian = !msg.is_bigendian; + const view = new DataView(rawBytes.buffer, rawBytes.byteOffset, rawBytes.byteLength); + + let writeIndex = 0; + for (let i = 0; i < pointCount; i += sampleRatio) { + const base = i * msg.point_step; + const outBase = writeIndex * 3; + + const x = readPointField(view, base + fx.offset, fx.datatype, littleEndian); + const y = readPointField(view, base + fy.offset, fy.datatype, littleEndian); + const z = readPointField(view, base + fz.offset, fz.datatype, littleEndian); + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) continue; + + positions[outBase] = x; + positions[outBase + 1] = y; + positions[outBase + 2] = z; + + if (fi) { + const intensity = readPointField(view, base + fi.offset, fi.datatype, littleEndian); + values[writeIndex] = Number.isFinite(intensity) ? intensity : z; + } else { + values[writeIndex] = z; + } + + if (rgb) { + if (frgbPacked) { + const packed = readPackedRgb(view, base + frgbPacked.offset, frgbPacked.datatype, littleEndian); + rgb[outBase] = (packed >> 16) & 0xFF; + rgb[outBase + 1] = (packed >> 8) & 0xFF; + rgb[outBase + 2] = packed & 0xFF; + } else if (fr && fg && fb) { + rgb[outBase] = toU8(readPointField(view, base + fr.offset, fr.datatype, littleEndian)); + rgb[outBase + 1] = toU8(readPointField(view, base + fg.offset, fg.datatype, littleEndian)); + rgb[outBase + 2] = toU8(readPointField(view, base + fb.offset, fb.datatype, littleEndian)); + } + } + + writeIndex++; + if (writeIndex >= sampledCount) break; + } + + return { + positions: positions.subarray(0, writeIndex * 3), + values: values.subarray(0, writeIndex), + rgb: rgb ? rgb.subarray(0, writeIndex * 3) : undefined, + }; +} + +export function inferColorModeFromFields(fields: PointFieldJson[] | undefined): ColorMode { + if (!Array.isArray(fields) || fields.length === 0) return 'FLAT'; + + const fieldMap = new Map(); + for (const field of fields) fieldMap.set(field.name, field); + + const frgbPacked = fieldMap.get('rgb') ?? fieldMap.get('rgba'); + const fr = fieldMap.get('r') ?? fieldMap.get('red'); + const fg = fieldMap.get('g') ?? fieldMap.get('green'); + const fb = fieldMap.get('b') ?? fieldMap.get('blue'); + const fi = fieldMap.get('intensity'); + + if (frgbPacked || (fr && fg && fb)) return 'RGB'; + if (fi) return 'I'; + return 'FLAT'; +} + +function readPointField(view: DataView, byteOffset: number, datatype: number, littleEndian: boolean): number { + switch (datatype) { + case PF_INT8: return view.getInt8(byteOffset); + case PF_UINT8: return view.getUint8(byteOffset); + case PF_INT16: return view.getInt16(byteOffset, littleEndian); + case PF_UINT16: return view.getUint16(byteOffset, littleEndian); + case PF_INT32: return view.getInt32(byteOffset, littleEndian); + case PF_UINT32: return view.getUint32(byteOffset, littleEndian); + case PF_FLOAT32: return view.getFloat32(byteOffset, littleEndian); + case PF_FLOAT64: return view.getFloat64(byteOffset, littleEndian); + default: return NaN; + } +} + +function readPackedRgb(view: DataView, byteOffset: number, datatype: number, littleEndian: boolean): number { + if (datatype === PF_FLOAT32) { + return view.getUint32(byteOffset, littleEndian); + } + const numeric = readPointField(view, byteOffset, datatype, littleEndian); + return (Math.max(0, Math.trunc(numeric)) >>> 0); +} + +function toU8(value: number): number { + const scaled = value <= 1 ? value * 255 : value; + return Math.max(0, Math.min(255, Math.round(scaled))); +} + +function decodeBase64(base64: string): Uint8Array { + const binary = atob(base64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i); + } + return out; +} diff --git a/src/utils/realtimeTypes.ts b/src/utils/realtimeTypes.ts new file mode 100644 index 0000000..a83bddc --- /dev/null +++ b/src/utils/realtimeTypes.ts @@ -0,0 +1,50 @@ +export type ColorMode = 'FLAT' | 'I' | 'RGB'; + +export interface PointFieldJson { + name: string; + offset: number; + datatype: number; + count: number; +} + +export interface DecodedCloudChunk { + positions: Float32Array; + values: Float32Array; + rgb?: Uint8Array; + maxAccumulatedPoints: number; +} + +export interface PointCloud2Json { + height: number; + width: number; + fields: PointFieldJson[]; + is_bigendian: boolean; + point_step: number; + row_step: number; + data: string; + is_dense: boolean; +} + +export interface RosbridgePublishMessage { + op?: string; + topic?: string; + msg?: unknown; +} + +export interface RealtimeTopicOptions { + topicName?: string; + cloudTopicName?: string; + odomTopicName?: string; + maxPointsPerScan?: number; + maxAccumulatedPoints?: number; + autoFitOnFirstChunk?: boolean; +} + +export interface OdomJson { + pose?: { + pose?: { + position?: { x?: number; y?: number; z?: number }; + orientation?: { x?: number; y?: number; z?: number; w?: number }; + }; + }; +} diff --git a/src/viewer.ts b/src/viewer.ts index 595bd86..fc2f62f 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -1,112 +1,37 @@ import * as THREE from 'three'; -import { CloudItem, CloudShaderMaterial } from './items/CloudItem'; import { AxisItem } from './items/AxisItem'; import { GridItem } from './items/GridItem'; import { Text2DItem } from './items/Text2DItem'; -import { Text3DItem, Text3DData } from './items/Text3DItem'; -import { GNSSMapItem } from './items/GNSSMapItem'; -import { FilmMaker, KeyFrame } from './filmMaker'; -import { recoverCenterEuler } from './utils/maths'; -import { parseLASGeoInfo, readLASBounds } from './utils/lasGeo'; -import { projToLatLon, registerWKT, convertByKey } from './utils/projConvert'; +import { Text3DItem } from './items/Text3DItem'; +import { eulerToMatrix4 } from './utils/maths'; import { - detectHeapLimit, - detectHeapUsed, - estimateMemoryRequirement, - formatBytes, -} from './utils/memoryCheck'; - -// Minimal PCD Header Parser for Streaming -interface PCDHeader { - data: 'ascii' | 'binary' | 'binary_compressed'; - headerLen: number; - width: number; - height: number; - points: number; - rowSize: number; - offset: { [key: string]: number }; - fields?: string[]; - counts?: number[]; - types?: string[]; - sizes?: number[]; -} - -interface LASBounds { - minX: number; - maxX: number; - minY: number; - maxY: number; - minZ: number; - maxZ: number; -} - -interface LASMetadata { - versionMajor: number; - versionMinor: number; - offsetToPointData: number; - pointDataRecordFormat: number; - pointDataRecordLength: number; - numberOfPoints: number; - xScale: number; - yScale: number; - zScale: number; - xOff: number; - yOff: number; - zOff: number; - hasRGB: boolean; - rgbOffset: number; - shiftX: number; - shiftY: number; - originLatLon: [number, number] | null; - bounds: LASBounds | null; -} - -interface LASStreamState extends LASMetadata { - rawPointIndex: number; -} - -function eulerToMatrix(roll: number, pitch: number, yaw: number): THREE.Matrix4 { - const cx = Math.cos(roll), sx = Math.sin(roll); - const cy = Math.cos(pitch), sy = Math.sin(pitch); - const cz = Math.cos(yaw), sz = Math.sin(yaw); - const m = new THREE.Matrix4(); - m.set( - cz*cy, cz*sy*sx - sz*cx, cz*sy*cx + sz*sx, 0, - sz*cy, sz*sy*sx + cz*cx, sz*sy*cx - cz*sx, 0, - -sy, cy*sx, cy*cx, 0, - 0, 0, 0, 1 - ); - return m; -} - -/** Interface for settings that items can provide */ -interface SettingBuilder { - addSetting(container: HTMLElement): void; -} + makeLabel, makeTextInput, makeCheckbox, buildCloudItemSettings, +} from './viewer/settingsUI'; +import { + setupMouseControls as _setupMouse, setupKeyboardControls as _setupKeys, updateCameraMovement, +} from './viewer/mouseControls'; +import { + addMeasurementPoint as _addMeasure, removeMeasurementPoint as _removeMeasure, + updateMeasurementMarker as _updateMeasureMarker, +} from './viewer/measurement'; +import { CloudItem } from './items/CloudItem'; -/** True if the event target is an input/textarea/select — used to suppress global keyboard shortcuts. */ -function isEditable(t: EventTarget | null): boolean { - if (!t) return false; - const el = t as HTMLElement; - const tag = el.tagName?.toUpperCase?.(); - return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable === true; -} +const wrapAngle = (a: number): number => + ((a + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) - Math.PI; +interface SettingBuilder { addSetting(container: HTMLElement): void; } export class Viewer { container: HTMLElement; scene: THREE.Scene; camera: THREE.PerspectiveCamera; renderer: THREE.WebGLRenderer; - items: {[name: string]: THREE.Object3D} = {}; - /** Items that should not appear in settings */ + items: { [name: string]: THREE.Object3D } = {}; hiddenSettingItems: Set = new Set(); - // Camera state (matching q3dviewer) euler: [number, number, number] = [Math.PI / 3, 0, Math.PI / 4]; cameraCenter: THREE.Vector3 = new THREE.Vector3(0, 0, 0); cameraDist: number = 40; - // Keyboard / Mouse state activeKeys: Set = new Set(); showCenter: boolean = false; enableShowCenter: boolean = true; @@ -115,185 +40,80 @@ export class Viewer { shiftPressed: boolean = false; ctrlPressed: boolean = false; centerPointMesh: THREE.Points | null = null; - - // Settings panel settingsPanel: HTMLElement | null = null; settingsContent: HTMLElement | null = null; settingsItemSelect: HTMLSelectElement | null = null; - - // Status & UI statusElement: HTMLElement | null = null; loadingOverlay: HTMLElement; - - // Measurement (Ctrl+click like cloud_viewer) selectedPoints: THREE.Vector3[] = []; text2dItem: Text2DItem | null = null; - // Supported extensions - static readonly SUPPORTED_EXTENSIONS = ['.pcd', '.ply', '.las', '.laz', '.e57']; - - // When true, skip the heap-size safety check before loading a file. - // Useful for automated tests and headless environments. - skipMemoryCheck: boolean = false; - - // Streaming state - currentFormat: 'pcd' | 'ply' | 'las' | 'laz' | 'e57' | 'unknown' = 'pcd'; - streamFilename: string | undefined = undefined; - streamTotalSize: number = 0; - streamLoadedSize: number = 0; - streamAborted: boolean = false; - pcdHeader: PCDHeader | null = null; - lasStream: LASStreamState | null = null; - isBinary: boolean = false; - leftoverChunk: Uint8Array | null = null; - pointsLoaded: number = 0; - targetSampleRatio: number = 1; - fullBufferWriteOffset: number = 0; - chunkList: Uint8Array[] = []; - - // Visualization Buffers - MAX_POINTS_VISUAL = 15_000_000; - posBuffer: Float32Array | null = null; - valBuffer: Float32Array | null = null; - rgbBuffer: Uint8Array | null = null; - fullBuffer: Uint8Array | null = null; - posIndex: number = 0; - - // Data range - dataMin: number = 0; - dataMax: number = 255; - - // Rendering renderRequested: boolean = false; animationFrameId: number = 0; - - // Background color string for settings colorStr: string = 'black'; - // Film Maker - filmMaker: FilmMaker = new FilmMaker(); - /** When the Film Maker tab is active, Space/Delete bind to add/delete keyframes. */ - filmMakerTabActive: boolean = false; - /** Playback state. */ - private filmPlaybackIndex: number = 0; - private filmPlaybackRequestId: number | null = null; - private filmPlaybackLastTimestamp: number | null = null; - private filmPlaybackAccumulatorMs: number = 0; - isPlayingFilm: boolean = false; - isRecordingFilm: boolean = false; - private mediaRecorder: MediaRecorder | null = null; - private recordedChunks: Blob[] = []; - lastRecordedBlob: Blob | null = null; - videoFileName: string = 'q3dweb.mp4'; - videoMimeType: string = 'video/mp4;codecs=h264'; - recordingVideoBitsPerSecond: number = 32_000_000; - recordingPixelRatioMin: number = 2; - private filmMakerListEl: HTMLElement | null = null; - private filmMakerPlayBtn: HTMLButtonElement | null = null; - private rendererPixelRatio: number = 1; + rendererPixelRatio: number = 1; constructor(containerId: string) { const container = document.getElementById(containerId); if (!container) throw new Error(`Container ${containerId} not found`); this.container = container; - - // Loading Overlay (Center) this.loadingOverlay = document.createElement('div'); - this.loadingOverlay.style.cssText = ` - position: absolute; top: 0; left: 0; width: 100%; height: 100%; - background: rgba(0,0,0,0.5); display: none; - justify-content: center; align-items: center; z-index: 1001; - `; - this.loadingOverlay.innerHTML = '
Loading...
'; + this.loadingOverlay.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:none;justify-content:center;align-items:center;z-index:1001;'; + this.loadingOverlay.innerHTML = '
Loading...
'; this.container.appendChild(this.loadingOverlay); - this.installGlobalErrorHandler(); - - // Scene this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x000000); - - // Camera this.camera = new THREE.PerspectiveCamera(60, this.container.clientWidth / this.container.clientHeight, 0.1, 1000); - this.camera.position.set(10, 10, 10); this.camera.up.set(0, 0, 1); - - // Renderer this.renderer = new THREE.WebGLRenderer({ antialias: true }); this.rendererPixelRatio = this.getBaseRendererPixelRatio(); this.renderer.setPixelRatio(this.rendererPixelRatio); this.renderer.setSize(this.container.clientWidth, this.container.clientHeight); - // Make the canvas focusable so clicking it steals focus from form inputs, - // ensuring keyboard shortcuts (e.g. M to toggle settings) work as expected. this.renderer.domElement.tabIndex = 0; this.renderer.domElement.style.outline = 'none'; this.container.appendChild(this.renderer.domElement); - - // Camera controls - this.setupMouseControls(); - this.setupKeyboardControls(); + _setupMouse(this.renderer.domElement, this as any); + _setupKeys(this as any); this.updateCamera(); - - // Default items (matching cloud_viewer) this.addDefaultItems(); - - // Center point visualization this.createCenterPoint(); - - // Settings panel (toggled by 'm' key) this.createSettingsPanel(); - - // Drag and Drop - this.setupDragDrop(); - - // Resize handler window.addEventListener('resize', this.onWindowResize.bind(this), false); - - // Animation loop this.startAnimationLoop(); } - // ========== Default Items (matching cloud_viewer) ========== - addDefaultItems() { const grid = new GridItem({ size: 1000, spacing: 20 }); grid.renderCb = () => this.requestRender(); this.addItem('grid', grid); - - const axis = new AxisItem({ size: 0.5, width: 5 }); - this.addItem('axis', axis); + this.addItem('axis', new AxisItem({ size: 0.5, width: 5 })); this.hiddenSettingItems.add('axis'); - - // Text2DItem for distance display (top-right overlay) this.text2dItem = new Text2DItem(this.container, { - text: '', - color: '#b3ffb3', - fontSize: 14, - anchor: 'top-right', - pos: [16, 16], - background: 'rgba(0,0,0,0.55)', - padding: '8px 12px', + text: '', color: '#b3ffb3', fontSize: 14, anchor: 'top-right', + pos: [16, 16], background: 'rgba(0,0,0,0.55)', padding: '8px 12px', }); this.text2dItem.hide(); this.hiddenSettingItems.add('text'); - - // Marker (Text3DItem) for measurement points - const marker = new Text3DItem(); - this.addItem('marker', marker); + this.addItem('marker', new Text3DItem()); this.hiddenSettingItems.add('marker'); } - // ========== Camera Control Methods (matching q3dviewer) ========== + createCenterPoint() { + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute([0, 0, 0], 3)); + this.centerPointMesh = new THREE.Points(geo, new THREE.PointsMaterial({ color: 0xff0000, size: 8, sizeAttenuation: false })); + this.centerPointMesh.visible = false; + this.scene.add(this.centerPointMesh); + } updateCamera() { const [roll, pitch, yaw] = this.euler; - const Rwc = eulerToMatrix(roll, pitch, yaw); - const offset = new THREE.Vector3(0, 0, this.cameraDist); - offset.applyMatrix4(Rwc); - const camPos = this.cameraCenter.clone().add(offset); - this.camera.position.copy(camPos); - const up = new THREE.Vector3(0, 1, 0).applyMatrix4(Rwc); - this.camera.up.copy(up); + const Rwc = eulerToMatrix4(roll, pitch, yaw); + const offset = new THREE.Vector3(0, 0, this.cameraDist).applyMatrix4(Rwc); + this.camera.position.copy(this.cameraCenter.clone().add(offset)); + this.camera.up.copy(new THREE.Vector3(0, 1, 0).applyMatrix4(Rwc)); this.camera.lookAt(this.cameraCenter); this.updateProjectionMatrixLikeQ3DViewer(); @@ -318,23 +138,19 @@ export class Viewer { } rotateCam(rx: number, ry: number, rz: number) { - this.euler[0] += rx; - this.euler[1] += ry; - this.euler[2] += rz; - this.euler[0] = Math.max(0, Math.min(Math.PI, this.euler[0])); - this.euler[1] = ((this.euler[1] + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) - Math.PI; - this.euler[2] = ((this.euler[2] + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) - Math.PI; + this.euler[0] = Math.max(0, Math.min(Math.PI, this.euler[0] + rx)); + this.euler[1] = wrapAngle(this.euler[1] + ry); + this.euler[2] = wrapAngle(this.euler[2] + rz); this.updateCamera(); } rotateKeepCamPos(rx: number, ry: number, rz: number) { - const newEuler: [number, number, number] = [ - this.euler[0] + rx, this.euler[1] + ry, this.euler[2] + rz + const ne: [number, number, number] = [ + Math.max(0, Math.min(Math.PI, this.euler[0] + rx)), + wrapAngle(this.euler[1] + ry), + wrapAngle(this.euler[2] + rz), ]; - newEuler[0] = Math.max(0, Math.min(Math.PI, newEuler[0])); - newEuler[1] = ((newEuler[1] + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) - Math.PI; - newEuler[2] = ((newEuler[2] + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) - Math.PI; - const RwcOld = eulerToMatrix(this.euler[0], this.euler[1], this.euler[2]); + const RwcOld = eulerToMatrix4(this.euler[0], this.euler[1], this.euler[2]); const tco = new THREE.Vector3(0, 0, this.cameraDist); const twc = this.cameraCenter.clone().add(tco.clone().applyMatrix4(RwcOld)); const RwcNew = eulerToMatrix(newEuler[0], newEuler[1], newEuler[2]); @@ -534,132 +350,29 @@ export class Viewer { this.requestRender(); } - // ========== Keyboard Controls ========== - - setupKeyboardControls() { - window.addEventListener('keydown', (e: KeyboardEvent) => { - this.activeKeys.add(e.key.toLowerCase()); - if (e.key === 'Shift') this.shiftPressed = true; - if (e.key === 'Control' || e.key === 'Meta') this.ctrlPressed = true; - if (e.key.toLowerCase() === 'm') { - // Skip M when user is typing in a text input, but NOT for - // type-ahead which would - // otherwise jump to "main win..." because it starts with 'm'. - const tag = (e.target as HTMLElement | null)?.tagName?.toUpperCase?.(); - if (tag === 'INPUT' || tag === 'TEXTAREA' - || (e.target as HTMLElement | null)?.isContentEditable === true) { - return; - } - if (tag === 'SELECT') { - e.preventDefault(); - (e.target as HTMLSelectElement).blur(); - } - this.toggleSettingsPanel(); - } - - // Film Maker shortcuts (only when the Film Maker tab is active and the - // focus is not an editable field). - if (this.filmMakerTabActive && !isEditable(e.target)) { - if (e.key === ' ' || e.code === 'Space') { - e.preventDefault(); - this.addKeyFrameFromCamera(); - } else if (e.key === 'Delete') { - e.preventDefault(); - this.deleteCurrentKeyFrame(); - } - } - }); - - window.addEventListener('keyup', (e: KeyboardEvent) => { - this.activeKeys.delete(e.key.toLowerCase()); - if (e.key === 'Shift') this.shiftPressed = false; - if (e.key === 'Control' || e.key === 'Meta') this.ctrlPressed = false; - }); - } - - updateMovement() { - if (this.activeKeys.size === 0) return; - const rotSpeed = 0.5; - const transSpeed = Math.max(this.cameraDist * 0.005, 0.1); - - if (this.activeKeys.has('arrowup')) { - if (this.shiftPressed) this.rotateKeepCamPos(rotSpeed * Math.PI / 180, 0, 0); - else this.rotateCam(rotSpeed * Math.PI / 180, 0, 0); - } - if (this.activeKeys.has('arrowdown')) { - if (this.shiftPressed) this.rotateKeepCamPos(-rotSpeed * Math.PI / 180, 0, 0); - else this.rotateCam(-rotSpeed * Math.PI / 180, 0, 0); - } - if (this.activeKeys.has('arrowleft')) { - if (this.shiftPressed) this.rotateKeepCamPos(0, 0, rotSpeed * Math.PI / 180); - else this.rotateCam(0, 0, rotSpeed * Math.PI / 180); - } - if (this.activeKeys.has('arrowright')) { - if (this.shiftPressed) this.rotateKeepCamPos(0, 0, -rotSpeed * Math.PI / 180); - else this.rotateCam(0, 0, -rotSpeed * Math.PI / 180); - } - - if (this.activeKeys.has('z') || this.activeKeys.has('x')) { - const Rwc = eulerToMatrix(this.euler[0], this.euler[1], this.euler[2]); - if (this.activeKeys.has('z')) this.translateCam(new THREE.Vector3(0, 0, -transSpeed).applyMatrix4(Rwc)); - if (this.activeKeys.has('x')) this.translateCam(new THREE.Vector3(0, 0, transSpeed).applyMatrix4(Rwc)); - } - - if (this.activeKeys.has('w') || this.activeKeys.has('a') || - this.activeKeys.has('s') || this.activeKeys.has('d')) { - const Rz = eulerToMatrix(0, 0, this.euler[2]); - if (this.activeKeys.has('w')) this.translateCam(new THREE.Vector3(0, transSpeed, 0).applyMatrix4(Rz)); - if (this.activeKeys.has('s')) this.translateCam(new THREE.Vector3(0, -transSpeed, 0).applyMatrix4(Rz)); - if (this.activeKeys.has('a')) this.translateCam(new THREE.Vector3(-transSpeed, 0, 0).applyMatrix4(Rz)); - if (this.activeKeys.has('d')) this.translateCam(new THREE.Vector3(transSpeed, 0, 0).applyMatrix4(Rz)); - } - } - - // ========== Center Point Visualization ========== - - createCenterPoint() { - const geo = new THREE.BufferGeometry(); - geo.setAttribute('position', new THREE.Float32BufferAttribute([0, 0, 0], 3)); - const mat = new THREE.PointsMaterial({ color: 0xff0000, size: 8, sizeAttenuation: false }); - this.centerPointMesh = new THREE.Points(geo, mat); - this.centerPointMesh.visible = false; - this.scene.add(this.centerPointMesh); - } - - // ========== Settings Panel (matching q3dviewer SettingWindow) ========== + translateCam(trans: THREE.Vector3) { this.cameraCenter.add(trans); this.updateCamera(); } + updateDist(delta: number) { this.cameraDist = Math.max(0.1, this.cameraDist + delta); this.updateCamera(); } + updateMovement() { updateCameraMovement(this as any); } + addMeasurementPoint(e: MouseEvent) { _addMeasure(e, this as any); } + removeMeasurementPoint() { _removeMeasure(this as any); } + updateMeasurementMarker() { _updateMeasureMarker(this as any); } createSettingsPanel() { const panel = document.createElement('div'); - panel.style.cssText = ` - position: absolute; top: 10px; left: 10px; - background: rgba(20,20,20,0.92); color: #eee; - padding: 12px; border-radius: 8px; - font-family: monospace; font-size: 12px; - z-index: 1100; width: 260px; - display: block; max-height: calc(100% - 20px); overflow-y: auto; - border: 1px solid #555; - `; - - // Title + panel.style.cssText = 'position:absolute;top:10px;left:10px;background:rgba(20,20,20,0.92);color:#eee;padding:12px;border-radius:8px;font-family:monospace;font-size:12px;z-index:1100;width:260px;display:block;max-height:calc(100% - 20px);overflow-y:auto;border:1px solid #555;'; const title = document.createElement('div'); - title.style.cssText = 'font-size: 14px; font-weight: bold; margin-bottom: 8px; border-bottom: 1px solid #555; padding-bottom: 4px;'; + title.style.cssText = 'font-size:14px;font-weight:bold;margin-bottom:8px;border-bottom:1px solid #555;padding-bottom:4px;'; title.textContent = 'Settings (M to toggle)'; panel.appendChild(title); - - // Item selector (combo box matching SettingWindow) const select = document.createElement('select'); - select.style.cssText = 'width: 100%; margin-bottom: 8px; background: #333; color: #eee; border: 1px solid #666; padding: 4px; border-radius: 3px;'; + select.style.cssText = 'width:100%;margin-bottom:8px;background:#333;color:#eee;border:1px solid #666;padding:4px;border-radius:3px;'; select.onchange = () => this.onSettingsItemSelected(select.value); panel.appendChild(select); this.settingsItemSelect = select; - - // Item settings content (matches SettingWindow.layout) const content = document.createElement('div'); - content.style.cssText = 'border: 1px solid #444; border-radius: 4px; padding: 8px;'; + content.style.cssText = 'border:1px solid #444;border-radius:4px;padding:8px;'; panel.appendChild(content); this.settingsContent = content; - this.container.appendChild(panel); this.settingsPanel = panel; this.refreshSettingsItemList(); @@ -669,478 +382,105 @@ export class Viewer { if (!this.settingsPanel) return; const visible = this.settingsPanel.style.display !== 'none'; this.settingsPanel.style.display = visible ? 'none' : 'block'; - if (!visible) { - // Re-opening: if the previously-selected tab no longer exists - // (e.g. the item was removed while the panel was hidden), - // gracefully fall back to main_win. Otherwise keep whatever - // content is already rendered so the user sees exactly what - // they left behind (active tab, scroll position, in-progress - // input values, etc.). - const current = this.settingsItemSelect?.value; - const exists = !!current && Array.from(this.settingsItemSelect!.options).some(o => o.value === current); - if (!exists && this.settingsItemSelect) { - this.settingsItemSelect.value = '__main_win__'; - this.onSettingsItemSelected('__main_win__'); - } - } + if (!visible) this.onSettingsItemSelected(this.settingsItemSelect?.value ?? '__main_win__'); } refreshSettingsItemList(preferredSelection?: string) { if (!this.settingsItemSelect) return; - const previousSelection = this.settingsItemSelect.value; - this.rebuildSettingsItemOptions(); - const desired = preferredSelection ?? previousSelection; - const exists = desired && Array.from(this.settingsItemSelect.options).some(opt => opt.value === desired); - this.settingsItemSelect.value = exists ? desired! : '__main_win__'; - // Skip the content rebuild while the panel is hidden: we want the - // user to see the same sub-panel they had open before closing, not - // a freshly-rendered one. The rebuild will still happen the next - // time the selector's onchange fires or when the panel is re-opened - // with a stale selection. - const visible = this.settingsPanel?.style.display !== 'none'; - if (visible) { - this.onSettingsItemSelected(this.settingsItemSelect.value); - } - } - - /** Rebuild only the