diff --git a/package-lock.json b/package-lock.json index 2565d55..eb5c970 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ioai/rosview", - "version": "1.7.1", + "version": "1.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ioai/rosview", - "version": "1.7.1", + "version": "1.7.2", "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/package.json b/package.json index a24531f..f55c1eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ioai/rosview", - "version": "1.7.1", + "version": "1.7.2", "description": "High-performance robotics data visualization for MCAP, ROS bag, ROS2 db3, HDF5 and BVH — embeddable React component and standalone SPA", "keywords": [ "ros", diff --git a/src/features/panels/ThreeD/ThreeDPanel.tsx b/src/features/panels/ThreeD/ThreeDPanel.tsx index 5c4c14e..9d67ef9 100644 --- a/src/features/panels/ThreeD/ThreeDPanel.tsx +++ b/src/features/panels/ThreeD/ThreeDPanel.tsx @@ -6,8 +6,14 @@ import type { MessagePipelineState } from '@/core/pipeline/store'; import { useMessagePipeline } from '@/core/pipeline/useMessagePipeline'; import { messageBus } from '@/core/pipeline/messageBus'; import { scheduleFrame } from '@/shared/utils/rafScheduler'; -import { parsePointCloud2 } from '@/shared/utils/pointCloud'; import type { PointCloudData } from '@/shared/utils/pointCloud'; +import { copyToTransferableArrayBuffer } from '@/shared/utils/pointCloud'; +import PointCloudParseWorkerClass from './core/PointCloudParse.worker.ts?worker&inline'; +import type { + PointCloudFieldWire, + PointCloudParseRequest, + PointCloudParseResponse, +} from './core/pointCloudWorkerProtocol'; import { transformBvhPointToScene } from '@/shared/bvh/coordinates'; import { getScenePanelThemeColors, type ScenePanelThemeColors } from '@/features/panels/common/scenePanelTheme'; import { @@ -20,6 +26,7 @@ import { CANVAS_GL, DEFAULT_GRID_DIVISIONS, DEFAULT_GRID_SIZE, + framePerspectiveCameraToBox, framePerspectiveCameraToGrid, Z_UP, } from '@/features/panels/common/zUpSceneLayout'; @@ -317,35 +324,350 @@ const ZUpGrid: React.FC = ({ }; // ── Point cloud ──────────────────────────────────────────────────── -// `useMemo` alone would leak the previous BufferGeometry on every new point -// cloud message (useMemo does not dispose the old value). We own the lifetime -// here and explicitly dispose the previous geometry when `data` changes, and -// when the component unmounts. +// Geometry/attributes are reused across frames when the point count is +// unchanged. Rebuilding BufferGeometry every message forced Three.js to +// recompute boundingSphere (full point scan) and made R3F reconcile a new +// object identity each tick. +type PointCloudGpuState = { + geometry: THREE.BufferGeometry; + position: THREE.BufferAttribute; + color: THREE.BufferAttribute | null; + /** Allocated point capacity (may exceed current drawCount). */ + capacity: number; + drawCount: number; +}; + +function disposePointCloudGpu(state: PointCloudGpuState | null): void { + if (!state) return; + state.geometry.dispose(); +} + +function safeComputeBoundingSphere(geometry: THREE.BufferGeometry): void { + geometry.computeBoundingSphere(); + const sphere = geometry.boundingSphere; + if (!sphere || !Number.isFinite(sphere.radius) || sphere.radius < 0) { + geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), 1); + } +} + +function setAttributeDrawCount(attribute: THREE.BufferAttribute, count: number): void { + // Three.js typings mark `count` readonly; runtime still honors updates used with setDrawRange. + (attribute as THREE.BufferAttribute & { count: number }).count = count; +} + +function applyPointCloudData( + prev: PointCloudGpuState | null, + data: PointCloudData, +): PointCloudGpuState { + const count = data.count; + const hasColors = data.colors != undefined && data.colors.length >= count * 3; + const desiredCapacity = Math.max(data.maxPoints ?? count, count, 1); + + // Reuse GPU buffers whenever the new cloud fits — valid-point count often + // fluctuates on is_dense=false depth clouds; avoid rebuild every frame. + if (prev && count <= prev.capacity) { + prev.position.array.set(data.positions.subarray(0, count * 3)); + setAttributeDrawCount(prev.position, count); + prev.position.needsUpdate = true; + if (hasColors) { + if (prev.color) { + prev.color.array.set(data.colors!.subarray(0, count * 3)); + setAttributeDrawCount(prev.color, count); + prev.color.needsUpdate = true; + } else { + const colorArray = new Float32Array(prev.capacity * 3); + colorArray.set(data.colors!.subarray(0, count * 3)); + const colorAttr = new THREE.BufferAttribute(colorArray, 3); + setAttributeDrawCount(colorAttr, count); + colorAttr.setUsage(THREE.DynamicDrawUsage); + prev.geometry.setAttribute('color', colorAttr); + prev.color = colorAttr; + } + } else if (prev.color) { + prev.geometry.deleteAttribute('color'); + prev.color = null; + } + prev.geometry.setDrawRange(0, count); + prev.drawCount = count; + return prev; + } + + disposePointCloudGpu(prev); + const capacity = desiredCapacity; + const geometry = new THREE.BufferGeometry(); + + const positionArray = new Float32Array(capacity * 3); + if (count > 0) { + positionArray.set(data.positions.subarray(0, count * 3)); + } + const position = new THREE.BufferAttribute(positionArray, 3); + setAttributeDrawCount(position, count); + position.setUsage(THREE.DynamicDrawUsage); + geometry.setAttribute('position', position); + + let color: THREE.BufferAttribute | null = null; + if (hasColors && count > 0) { + const colorArray = new Float32Array(capacity * 3); + colorArray.set(data.colors!.subarray(0, count * 3)); + color = new THREE.BufferAttribute(colorArray, 3); + setAttributeDrawCount(color, count); + color.setUsage(THREE.DynamicDrawUsage); + geometry.setAttribute('color', color); + } + + geometry.setDrawRange(0, count); + if (count > 0) { + safeComputeBoundingSphere(geometry); + } else { + geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), 1); + } + return { geometry, position, color, capacity, drawCount: count }; +} + +/** Low-frequency clouds (LaserScan / OccupancyGrid) that arrive via React props. */ const PointCloud = ({ data, color, size }: { data: PointCloudData; color: string; size: number }) => { - const geometryRef = useRef(null); + const gpuRef = useRef(null); const [geometry, setGeometry] = useState(null); + const [hasVertexColors, setHasVertexColors] = useState(false); + const { invalidate } = useThree(); + + useEffect(() => { + const next = applyPointCloudData(gpuRef.current, data); + gpuRef.current = next; + setGeometry(next.geometry); + setHasVertexColors(next.color != null); + invalidate(); + }, [data, invalidate]); useEffect(() => { - const geo = new THREE.BufferGeometry(); - geo.setAttribute('position', new THREE.BufferAttribute(data.positions, 3)); - const previous = geometryRef.current; - geometryRef.current = geo; - setGeometry(geo); - if (previous) previous.dispose(); return () => { - // Only dispose in the unmount cleanup; normal updates dispose above so - // this runs a single time when the component actually unmounts. - if (geometryRef.current === geo) { - geometryRef.current = null; - geo.dispose(); + disposePointCloudGpu(gpuRef.current); + gpuRef.current = null; + }; + }, []); + + if (!geometry) return null; + return ( + + + + ); +}; + +/** + * High-frequency PointCloud2 layer. Parsing runs in a dedicated worker; + * transferable ArrayBuffers come back so the main thread only uploads to GPU. + * Intermediate frames are coalesced (latest-only) while a parse is in flight. + */ +const LivePointCloudLayer = ({ + player, + panelId, + topic, + color, + size, +}: { + player: Player; + panelId: string; + topic: string; + color: string; + size: number; +}) => { + const gpuRef = useRef(null); + const [geometry, setGeometry] = useState(null); + const [hasVertexColors, setHasVertexColors] = useState(false); + const { invalidate, camera, controls } = useThree(); + const cameraRef = useRef(camera); + const controlsRef = useRef(controls); + const invalidateRef = useRef(invalidate); + const geometryIdentityRef = useRef(null); + const hasColorsRef = useRef(false); + const workerRef = useRef(null); + const nextIdRef = useRef(1); + const inflightIdRef = useRef(null); + const pendingJobRef = useRef(null); + const didAutofitRef = useRef(false); + + cameraRef.current = camera; + controlsRef.current = controls; + invalidateRef.current = invalidate; + + useEffect(() => { + didAutofitRef.current = false; + }, [topic]); + + useEffect(() => { + const worker = new PointCloudParseWorkerClass(); + workerRef.current = worker; + + const autofitOnce = (geo: THREE.BufferGeometry) => { + if (didAutofitRef.current) return; + const cam = cameraRef.current; + if (!(cam instanceof THREE.PerspectiveCamera)) return; + safeComputeBoundingSphere(geo); + const sphere = geo.boundingSphere; + if (!sphere || !Number.isFinite(sphere.radius) || sphere.radius <= 0) return; + const box = new THREE.Box3().setFromBufferAttribute( + geo.getAttribute('position') as THREE.BufferAttribute, + ); + if (box.isEmpty()) return; + const target = framePerspectiveCameraToBox(cam, box); + const oc = controlsRef.current as ThreeOrbitControls | null; + if (oc) { + oc.target.copy(target); + oc.update(); } + didAutofitRef.current = true; }; - }, [data]); + + const applyParsed = ( + positions: Float32Array, + colors: Float32Array | undefined, + pointCount: number, + maxPoints: number, + ) => { + if (pointCount <= 0) return; + const parsed: PointCloudData = { + positions, + colors, + count: pointCount, + maxPoints, + }; + const next = applyPointCloudData(gpuRef.current, parsed); + gpuRef.current = next; + const colorsChanged = (next.color != null) !== hasColorsRef.current; + const geometryChanged = next.geometry !== geometryIdentityRef.current; + if (geometryChanged || colorsChanged) { + geometryIdentityRef.current = next.geometry; + hasColorsRef.current = next.color != null; + setGeometry(next.geometry); + setHasVertexColors(next.color != null); + } + autofitOnce(next.geometry); + invalidateRef.current(); + }; + + const flushPending = () => { + const pending = pendingJobRef.current; + if (!pending || inflightIdRef.current != null) { + return; + } + pendingJobRef.current = null; + inflightIdRef.current = pending.id; + worker.postMessage(pending, [pending.data]); + }; + + worker.onmessage = (event: MessageEvent) => { + const response = event.data; + if (response.id !== inflightIdRef.current) { + flushPending(); + return; + } + inflightIdRef.current = null; + if (response.type === 'parsed') { + const positions = new Float32Array(response.positions); + const colors = response.colors ? new Float32Array(response.colors) : undefined; + applyParsed(positions, colors, response.pointCount, response.maxPoints); + } + flushPending(); + }; + + const consumerId = `${panelId}:pointcloud`; + let cachedFields: PointCloudFieldWire[] | null = null; + player.registerHighFrequencyConsumer(consumerId, { + topic, + lane: 'pointcloud', + mode: 'latest', + onLatestMessage: (msg) => { + const message = msg.message; + if (!message || typeof message !== 'object') { + return; + } + const record = message as Record; + const data = record.data; + if (!(data instanceof Uint8Array)) { + return; + } + if (!Array.isArray(record.fields) || typeof record.point_step !== 'number') { + return; + } + if (typeof record.width !== 'number' || typeof record.height !== 'number') { + return; + } + + if (!cachedFields) { + const fields: PointCloudFieldWire[] = []; + for (const field of record.fields) { + if (!field || typeof field !== 'object') continue; + const f = field as Record; + if (typeof f.name !== 'string' || typeof f.offset !== 'number') continue; + fields.push({ + name: f.name, + offset: f.offset, + datatype: typeof f.datatype === 'number' ? f.datatype : undefined, + }); + } + cachedFields = fields; + } + + const header = record.header; + const frameId = + header && typeof header === 'object' && typeof (header as Record).frame_id === 'string' + ? ((header as Record).frame_id as string) + : undefined; + + const payload = copyToTransferableArrayBuffer(data); + const id = nextIdRef.current++; + const job: PointCloudParseRequest = { + type: 'parse', + id, + fields: cachedFields, + pointStep: record.point_step, + width: record.width, + height: record.height, + isBigendian: record.is_bigendian === true, + topic, + frameId, + data: payload, + }; + pendingJobRef.current = job; + if (inflightIdRef.current == null) { + pendingJobRef.current = null; + inflightIdRef.current = id; + worker.postMessage(job, [payload]); + } + }, + }); + + return () => { + player.unregisterHighFrequencyConsumer(consumerId); + worker.onmessage = null; + worker.terminate(); + workerRef.current = null; + pendingJobRef.current = null; + inflightIdRef.current = null; + disposePointCloudGpu(gpuRef.current); + gpuRef.current = null; + geometryIdentityRef.current = null; + hasColorsRef.current = false; + setGeometry(null); + setHasVertexColors(false); + }; + }, [player, panelId, topic]); if (!geometry) return null; return ( - - + + ); }; @@ -706,7 +1028,8 @@ function extractLaserScanPoints(message: unknown): PointCloudData | null { positions.push(Math.cos(angle) * range, Math.sin(angle) * range, 0); } if (positions.length === 0) return null; - return { positions: new Float32Array(positions) }; + const positionsArray = new Float32Array(positions); + return { positions: positionsArray, count: positionsArray.length / 3 }; } function extractOccupancyGridPoints(message: unknown): PointCloudData | null { @@ -731,7 +1054,8 @@ function extractOccupancyGridPoints(message: unknown): PointCloudData | null { } } if (positions.length === 0) return null; - return { positions: new Float32Array(positions) }; + const positionsArray = new Float32Array(positions); + return { positions: positionsArray, count: positionsArray.length / 3 }; } // ── URDF mesh resolution (unchanged) ────────────────────────────── @@ -1009,7 +1333,6 @@ const Scene = ({ topicSettings: ThreeDTopicSetting[]; onMeshLoadProgressChange?: (progress: MeshLoadProgress | null) => void; }) => { - const [pointCloud, setPointCloud] = useState(null); const [tracks, setTracks] = useState([]); const [markerPrimitives, setMarkerPrimitives] = useState([]); const [skeletonPrimitives, setSkeletonPrimitives] = useState([]); @@ -1182,25 +1505,6 @@ const Scene = ({ return () => player.unregisterSubscriptions(panelId); }, [player, panelId, urdfTopic, jointsTopic, bvhTopic, enabledTopicSettings, tfTopics]); - useEffect(() => { - if (!pcTopic) { - return; - } - const consumerId = `${panelId}:pointcloud`; - player.registerHighFrequencyConsumer(consumerId, { - topic: pcTopic, - lane: 'pointcloud', - mode: 'latest', - onLatestMessage: (msg) => { - const parsed = parsePointCloud2(msg.message); - if (parsed) { - setPointCloud(parsed); - } - }, - }); - return () => player.unregisterHighFrequencyConsumer(consumerId); - }, [player, panelId, pcTopic]); - const tfTopicSet = useMemo(() => new Set(tfTopics), [tfTopics]); const topicSettingByName = useMemo( () => new Map(enabledTopicSettings.map((entry) => [entry.topic, entry])), @@ -1395,7 +1699,15 @@ const Scene = ({ ))} {showAxes && } - {pointCloud && } + {pcTopic && ( + + )} {laserScanCloud && } {occupancyCloud && } {tracks.map((track) => ( @@ -1423,7 +1735,7 @@ const Scene = ({ /> )} - {showPlaceholder && !pointCloud && !urdfText && skeletonPrimitives.length === 0 && ( + {showPlaceholder && !pcTopic && !urdfText && skeletonPrimitives.length === 0 && ( diff --git a/src/features/panels/ThreeD/core/PointCloudParse.worker.ts b/src/features/panels/ThreeD/core/PointCloudParse.worker.ts new file mode 100644 index 0000000..ce7a0cf --- /dev/null +++ b/src/features/panels/ThreeD/core/PointCloudParse.worker.ts @@ -0,0 +1,69 @@ +import { parsePointCloud2 } from '@/shared/utils/pointCloud'; +import type { + PointCloudParseRequest, + PointCloudParseResponse, + PointCloudParseSuccess, +} from './pointCloudWorkerProtocol'; + +/** `Float32Array.buffer` is typed as ArrayBufferLike; we only transfer plain ABs. */ +function transferableBuffer(view: Float32Array): ArrayBuffer { + const { buffer, byteOffset, byteLength } = view; + if (buffer instanceof ArrayBuffer && byteOffset === 0 && byteLength === buffer.byteLength) { + return buffer; + } + return buffer.slice(byteOffset, byteOffset + byteLength) as ArrayBuffer; +} + +self.onmessage = (event: MessageEvent) => { + const req = event.data; + if (!req || req.type !== 'parse') { + return; + } + + try { + const parsed = parsePointCloud2( + { + fields: req.fields, + data: new Uint8Array(req.data), + point_step: req.pointStep, + width: req.width, + height: req.height, + is_bigendian: req.isBigendian, + }, + { topic: req.topic, frameId: req.frameId }, + ); + + if (!parsed) { + const failure: PointCloudParseResponse = { + type: 'error', + id: req.id, + message: 'invalid PointCloud2', + }; + self.postMessage(failure); + return; + } + + const positionsBuffer = transferableBuffer(parsed.positions); + const transfer: Transferable[] = [positionsBuffer]; + const success: PointCloudParseSuccess = { + type: 'parsed', + id: req.id, + pointCount: parsed.count, + maxPoints: parsed.maxPoints ?? req.width * req.height, + positions: positionsBuffer, + }; + if (parsed.colors) { + const colorsBuffer = transferableBuffer(parsed.colors); + transfer.push(colorsBuffer); + success.colors = colorsBuffer; + } + self.postMessage(success, transfer); + } catch (err) { + const failure: PointCloudParseResponse = { + type: 'error', + id: req.id, + message: err instanceof Error ? err.message : String(err), + }; + self.postMessage(failure); + } +}; diff --git a/src/features/panels/ThreeD/core/pointCloudWorkerProtocol.ts b/src/features/panels/ThreeD/core/pointCloudWorkerProtocol.ts new file mode 100644 index 0000000..ef3d335 --- /dev/null +++ b/src/features/panels/ThreeD/core/pointCloudWorkerProtocol.ts @@ -0,0 +1,42 @@ +/** Protocol for the PointCloud2 parse worker (main ↔ worker). */ + +export type PointCloudFieldWire = { + name: string; + offset: number; + datatype?: number; +}; + +export type PointCloudParseRequest = { + type: 'parse'; + /** Monotonic id; main thread ignores stale responses. */ + id: number; + fields: PointCloudFieldWire[]; + pointStep: number; + width: number; + height: number; + isBigendian: boolean; + /** Topic name for optical-frame heuristic. */ + topic?: string; + /** `header.frame_id` when present. */ + frameId?: string; + /** Raw PointCloud2 `data` bytes (transferred). */ + data: ArrayBuffer; +}; + +export type PointCloudParseSuccess = { + type: 'parsed'; + id: number; + pointCount: number; + /** width*height — GPU buffer capacity hint. */ + maxPoints: number; + positions: ArrayBuffer; + colors?: ArrayBuffer; +}; + +export type PointCloudParseFailure = { + type: 'error'; + id: number; + message: string; +}; + +export type PointCloudParseResponse = PointCloudParseSuccess | PointCloudParseFailure; diff --git a/src/features/panels/common/zUpSceneLayout.ts b/src/features/panels/common/zUpSceneLayout.ts index 8813f4e..033a508 100644 --- a/src/features/panels/common/zUpSceneLayout.ts +++ b/src/features/panels/common/zUpSceneLayout.ts @@ -61,3 +61,59 @@ export function framePerspectiveCameraToGrid( camera.far = Math.max(6000, distance * 80); camera.updateProjectionMatrix(); } + +/** + * Frame a Z-up perspective camera to a point-cloud AABB so the view looks + * along ROS +X (forward), matching a depth/color camera "looking ahead". + * Returns the box center for OrbitControls.target. + */ +export function framePerspectiveCameraToBox( + camera: THREE.PerspectiveCamera, + box: THREE.Box3, + fillRatio: number = CAMERA_GRID_FILL_RATIO, +): THREE.Vector3 { + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + // Camera sits behind-and-slightly-above the cloud, looking toward +X. + const fromCenterToCamera = new THREE.Vector3(-1, 0, 0.28).normalize(); + const forward = fromCenterToCamera.clone().negate(); + const right = new THREE.Vector3().crossVectors(forward, Z_UP).normalize(); + const up = new THREE.Vector3().crossVectors(right, forward).normalize(); + + const vFov = THREE.MathUtils.degToRad(camera.fov); + const hFov = 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect); + const tanHalfV = Math.tan(vFov / 2); + const tanHalfH = Math.tan(hFov / 2); + + const half = size.clone().multiplyScalar(0.5); + const corners = [ + new THREE.Vector3(-half.x, -half.y, -half.z), + new THREE.Vector3(-half.x, -half.y, half.z), + new THREE.Vector3(-half.x, half.y, -half.z), + new THREE.Vector3(-half.x, half.y, half.z), + new THREE.Vector3(half.x, -half.y, -half.z), + new THREE.Vector3(half.x, -half.y, half.z), + new THREE.Vector3(half.x, half.y, -half.z), + new THREE.Vector3(half.x, half.y, half.z), + ]; + + let distance = 0.5; + for (const corner of corners) { + const towardCamera = corner.dot(fromCenterToCamera); + distance = Math.max( + distance, + towardCamera + Math.abs(corner.dot(right)) / (tanHalfH * fillRatio), + towardCamera + Math.abs(corner.dot(up)) / (tanHalfV * fillRatio), + ); + } + // Keep a little padding so near-plane points are not clipped. + distance = Math.max(distance, size.length() * 0.35 + 0.5); + + camera.up.copy(Z_UP); + camera.position.copy(center).addScaledVector(fromCenterToCamera, distance); + camera.lookAt(center); + camera.near = Math.max(0.01, distance / 1500); + camera.far = Math.max(6000, distance * 80); + camera.updateProjectionMatrix(); + return center; +} diff --git a/src/infra/workers/mcap.worker.ts b/src/infra/workers/mcap.worker.ts index d96e39e..817b687 100644 --- a/src/infra/workers/mcap.worker.ts +++ b/src/infra/workers/mcap.worker.ts @@ -27,12 +27,13 @@ import type { Range } from '@/shared/utils/ranges'; import { compactTimeRanges, inferProgressTimeRangeCompaction } from '@/shared/utils/timeRanges'; import { workerPerf } from './workerPerf'; import { DataQualityScanController } from './dataQualityScanController'; -type IndexedChunkCoverage = { - byteRange: Range; - timeRange: TimeRange; - startNs: bigint; - endNs: bigint; -}; +import { + getPlayableTimeRanges, + isByteRangeCovered, + type ChunkCoverage, +} from './playableTimeRanges'; + +type IndexedChunkCoverage = ChunkCoverage; const MIB = 1024 * 1024; const PREFETCH_CACHE_FRACTION = 0.75; @@ -43,10 +44,6 @@ const MAX_CONTIGUOUS_CHUNK_GAP_NS = 750_000_000n; const DEFAULT_PREFETCH_AHEAD_MS = 5_000; const PLAYBACK_CURSOR_BUFFER_AHEAD_MS = 1_500; -function isByteRangeCovered(query: Range, downloaded: readonly Range[]): boolean { - return downloaded.some((range) => range.start <= query.start && range.end >= query.end); -} - class McapWorkerImpl implements IWorkerSerializedSourceWorker { private _source?: McapIndexedIterableSource; private _cachedReadable?: CachedFilelike; @@ -223,7 +220,11 @@ class McapWorkerImpl implements IWorkerSerializedSourceWorker { const loadedBytes = downloadedByteRanges.reduce((sum, range) => sum + (range.end - range.start), 0); const totalBytes = this._totalBytes || (await this._cachedReadable.size()); const percent = totalBytes > 0 ? Math.min(100, (loadedBytes / totalBytes) * 100) : 0; - const coveredChunkRanges = this._getContiguousPlayableRanges(downloadedByteRanges); + const coveredChunkRanges = getPlayableTimeRanges( + this._chunkCoverage, + downloadedByteRanges, + MAX_CONTIGUOUS_CHUNK_GAP_NS, + ); const parsedMessageRanges = compactTimeRanges( coveredChunkRanges, inferProgressTimeRangeCompaction( @@ -356,31 +357,6 @@ class McapWorkerImpl implements IWorkerSerializedSourceWorker { return { byteRange: { start: byteStart, end: byteEnd }, endNs: includedEndNs }; } - private _getContiguousPlayableRanges(downloadedByteRanges: readonly Range[]): TimeRange[] { - const firstChunk = this._chunkCoverage[0]; - if (!firstChunk) { - return []; - } - - const ranges: TimeRange[] = []; - let endNs = firstChunk.startNs; - for (const chunk of this._chunkCoverage) { - if (chunk.startNs > endNs + MAX_CONTIGUOUS_CHUNK_GAP_NS) { - break; - } - if (!isByteRangeCovered(chunk.byteRange, downloadedByteRanges)) { - break; - } - - endNs = chunk.endNs > endNs ? chunk.endNs : endNs; - ranges[0] = { - start: { ...firstChunk.timeRange.start }, - end: fromNano(endNs), - }; - } - return ranges; - } - private _inferPrefetchTargetBytes(): number { const cacheBytes = this._remoteCacheBytes > 0 ? this._remoteCacheBytes : 512 * MIB; const target = Math.floor(cacheBytes * PREFETCH_CACHE_FRACTION); diff --git a/src/infra/workers/playableTimeRanges.test.ts b/src/infra/workers/playableTimeRanges.test.ts new file mode 100644 index 0000000..b515f4f --- /dev/null +++ b/src/infra/workers/playableTimeRanges.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { getPlayableTimeRanges, type ChunkCoverage } from './playableTimeRanges'; +import { toNano } from '@/shared/utils/time'; + +const GAP_NS = 750_000_000n; + +function chunk( + startSec: number, + endSec: number, + byteStart: number, + byteEnd: number, +): ChunkCoverage { + return { + startNs: BigInt(startSec) * 1_000_000_000n, + endNs: BigInt(endSec) * 1_000_000_000n, + timeRange: { + start: { sec: startSec, nsec: 0 }, + end: { sec: endSec, nsec: 0 }, + }, + byteRange: { start: byteStart, end: byteEnd }, + }; +} + +describe('getPlayableTimeRanges', () => { + it('returns empty when there is no chunk coverage', () => { + expect(getPlayableTimeRanges([], [{ start: 0, end: 100 }], GAP_NS)).toEqual([]); + }); + + it('returns a single prefix when early chunks are continuously covered', () => { + const chunks = [chunk(0, 1, 0, 10), chunk(1, 2, 10, 20), chunk(2, 3, 20, 30)]; + const ranges = getPlayableTimeRanges(chunks, [{ start: 0, end: 20 }], GAP_NS); + expect(ranges).toHaveLength(1); + expect(toNano(ranges[0].start)).toBe(0n); + expect(toNano(ranges[0].end)).toBe(2_000_000_000n); + }); + + it('returns the mid-file segment when early chunks were evicted', () => { + const chunks = [chunk(0, 1, 0, 10), chunk(1, 2, 10, 20), chunk(2, 3, 20, 30)]; + // Only mid/late bytes remain in cache (LRU evicted the start). + const ranges = getPlayableTimeRanges(chunks, [{ start: 10, end: 30 }], GAP_NS); + expect(ranges).toHaveLength(1); + expect(toNano(ranges[0].start)).toBe(1_000_000_000n); + expect(toNano(ranges[0].end)).toBe(3_000_000_000n); + }); + + it('returns multiple segments for disjoint downloaded ranges', () => { + const chunks = [ + chunk(0, 1, 0, 10), + chunk(1, 2, 10, 20), + chunk(2, 3, 20, 30), + chunk(3, 4, 30, 40), + ]; + const ranges = getPlayableTimeRanges( + chunks, + [ + { start: 0, end: 10 }, + { start: 30, end: 40 }, + ], + GAP_NS, + ); + expect(ranges).toHaveLength(2); + expect(toNano(ranges[0].start)).toBe(0n); + expect(toNano(ranges[0].end)).toBe(1_000_000_000n); + expect(toNano(ranges[1].start)).toBe(3_000_000_000n); + expect(toNano(ranges[1].end)).toBe(4_000_000_000n); + }); + + it('splits segments when the time gap exceeds the contiguous threshold', () => { + // 2s gap between chunk 0 end and chunk 1 start (> 750ms). + const chunks = [chunk(0, 1, 0, 10), chunk(3, 4, 10, 20)]; + const ranges = getPlayableTimeRanges(chunks, [{ start: 0, end: 20 }], GAP_NS); + expect(ranges).toHaveLength(2); + expect(toNano(ranges[0].start)).toBe(0n); + expect(toNano(ranges[0].end)).toBe(1_000_000_000n); + expect(toNano(ranges[1].start)).toBe(3_000_000_000n); + expect(toNano(ranges[1].end)).toBe(4_000_000_000n); + }); + + it('merges adjacent covered chunks within the gap threshold', () => { + // 0.5s gap (< 750ms) should still merge. + const chunks = [ + { + ...chunk(0, 1, 0, 10), + endNs: 1_000_000_000n, + }, + { + ...chunk(1, 2, 10, 20), + startNs: 1_500_000_000n, + endNs: 2_000_000_000n, + timeRange: { + start: { sec: 1, nsec: 500_000_000 }, + end: { sec: 2, nsec: 0 }, + }, + }, + ]; + const ranges = getPlayableTimeRanges(chunks, [{ start: 0, end: 20 }], GAP_NS); + expect(ranges).toHaveLength(1); + expect(toNano(ranges[0].start)).toBe(0n); + expect(toNano(ranges[0].end)).toBe(2_000_000_000n); + }); +}); diff --git a/src/infra/workers/playableTimeRanges.ts b/src/infra/workers/playableTimeRanges.ts new file mode 100644 index 0000000..c4110bd --- /dev/null +++ b/src/infra/workers/playableTimeRanges.ts @@ -0,0 +1,73 @@ +import type { TimeRange } from '@/core/types/ros'; +import type { Range } from '@/shared/utils/ranges'; +import { fromNano } from '@/shared/utils/time'; + +export type ChunkCoverage = { + byteRange: Range; + timeRange: TimeRange; + startNs: bigint; + endNs: bigint; +}; + +export function isByteRangeCovered(query: Range, downloaded: readonly Range[]): boolean { + return downloaded.some((range) => range.start <= query.start && range.end >= query.end); +} + +/** + * Map currently-downloaded byte ranges onto chunk time coverage. + * Returns every contiguous playable segment (not only a prefix from file start), + * so LRU eviction of early chunks does not collapse the buffer bar to empty. + */ +export function getPlayableTimeRanges( + chunkCoverage: readonly ChunkCoverage[], + downloadedByteRanges: readonly Range[], + maxContiguousGapNs: bigint, +): TimeRange[] { + if (chunkCoverage.length === 0) { + return []; + } + + const ranges: TimeRange[] = []; + let segmentStart: TimeRange['start'] | undefined; + let segmentEndNs: bigint | undefined; + + const flush = () => { + if (segmentStart == undefined || segmentEndNs == undefined) { + return; + } + ranges.push({ + start: { ...segmentStart }, + end: fromNano(segmentEndNs), + }); + segmentStart = undefined; + segmentEndNs = undefined; + }; + + for (const chunk of chunkCoverage) { + const covered = isByteRangeCovered(chunk.byteRange, downloadedByteRanges); + if (!covered) { + flush(); + continue; + } + + if (segmentStart == undefined || segmentEndNs == undefined) { + segmentStart = chunk.timeRange.start; + segmentEndNs = chunk.endNs; + continue; + } + + if (chunk.startNs > segmentEndNs + maxContiguousGapNs) { + flush(); + segmentStart = chunk.timeRange.start; + segmentEndNs = chunk.endNs; + continue; + } + + if (chunk.endNs > segmentEndNs) { + segmentEndNs = chunk.endNs; + } + } + + flush(); + return ranges; +} diff --git a/src/infra/workers/transports/SabTransport.ts b/src/infra/workers/transports/SabTransport.ts index 9e6beef..f239a1b 100644 --- a/src/infra/workers/transports/SabTransport.ts +++ b/src/infra/workers/transports/SabTransport.ts @@ -4,8 +4,8 @@ import type { IWorkerSerializedSourceWorker } from "../types"; import type { SharedPayloadRingConfig, TransportDiagnostics } from "../transport"; import type { WorkerTransport } from "./BaseWorkerTransport"; -const DEFAULT_RING_BYTES = 256 * 1024 * 1024; -const DEFAULT_SLOT_BYTES = 4 * 1024 * 1024; +const DEFAULT_RING_BYTES = 384 * 1024 * 1024; +const DEFAULT_SLOT_BYTES = 16 * 1024 * 1024; export class SabTransport implements WorkerTransport { private readonly _ringConfig: SharedPayloadRingConfig; diff --git a/src/shared/utils/pointCloud.test.ts b/src/shared/utils/pointCloud.test.ts new file mode 100644 index 0000000..8258156 --- /dev/null +++ b/src/shared/utils/pointCloud.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest'; +import { + copyToTransferableArrayBuffer, + opticalToRos, + parsePointCloud2, + PointFieldDatatype, + sampleTurbo, + shouldTreatAsOpticalFrame, +} from './pointCloud'; + +function packPclRgb(r: number, g: number, b: number): number { + const packed = ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff); + const buf = new ArrayBuffer(4); + new Uint32Array(buf)[0] = packed; + return new Float32Array(buf)[0]!; +} + +function buildCloud(options: { + fields: Array<{ name: string; offset: number; datatype: number }>; + pointStep: number; + points: Array<(view: DataView, offset: number) => void>; + isBigendian?: boolean; + frameId?: string; +}) { + const width = options.points.length; + const height = 1; + const data = new Uint8Array(width * options.pointStep); + const view = new DataView(data.buffer); + for (let i = 0; i < options.points.length; i++) { + options.points[i]!(view, i * options.pointStep); + } + return { + header: options.frameId ? { frame_id: options.frameId } : undefined, + fields: options.fields, + data, + point_step: options.pointStep, + width, + height, + is_bigendian: options.isBigendian ?? false, + }; +} + +const XYZ_FIELDS = [ + { name: 'x', offset: 0, datatype: PointFieldDatatype.FLOAT32 }, + { name: 'y', offset: 4, datatype: PointFieldDatatype.FLOAT32 }, + { name: 'z', offset: 8, datatype: PointFieldDatatype.FLOAT32 }, +]; + +describe('shouldTreatAsOpticalFrame', () => { + it('detects optical frame_id and depth/points topics', () => { + expect(shouldTreatAsOpticalFrame('camera_depth_optical_frame')).toBe(true); + expect(shouldTreatAsOpticalFrame(undefined, '/orbbec_free/depth/points')).toBe(true); + expect(shouldTreatAsOpticalFrame('map', '/lidar/points')).toBe(false); + }); +}); + +describe('opticalToRos', () => { + it('maps optical Z-forward Y-down to ROS X-forward Z-up', () => { + expect(opticalToRos(1, 2, 3)).toEqual([3, -1, -2]); + }); +}); + +describe('copyToTransferableArrayBuffer', () => { + it('returns a plain ArrayBuffer even when source is SharedArrayBuffer-backed', () => { + const sab = new SharedArrayBuffer(8); + const view = new Uint8Array(sab); + view.set([1, 2, 3, 4, 5, 6, 7, 8]); + const copied = copyToTransferableArrayBuffer(view); + expect(copied).toBeInstanceOf(ArrayBuffer); + expect(copied).not.toBeInstanceOf(SharedArrayBuffer); + expect([...new Uint8Array(copied)]).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + }); +}); + +describe('parsePointCloud2', () => { + it('converts optical xyz to ROS and colorizes by depth turbo for depth topics', () => { + const message = buildCloud({ + fields: XYZ_FIELDS, + pointStep: 16, + frameId: 'orbbec_depth_optical_frame', + points: [ + (view, offset) => { + view.setFloat32(offset, 1, true); + view.setFloat32(offset + 4, 2, true); + view.setFloat32(offset + 8, 3, true); + }, + (view, offset) => { + view.setFloat32(offset, 0, true); + view.setFloat32(offset + 4, 0, true); + view.setFloat32(offset + 8, 10, true); + }, + ], + }); + + const parsed = parsePointCloud2(message, { topic: '/orbbec_free/depth/points' }); + expect(parsed).not.toBeNull(); + expect(parsed!.count).toBe(2); + expect(parsed!.maxPoints).toBe(2); + expect(Array.from(parsed!.positions.subarray(0, 3))).toEqual([3, -1, -2]); + expect(parsed!.positions[3]).toBeCloseTo(10); + expect(parsed!.colors).toBeDefined(); + expect(parsed!.colors![0]).not.toBeCloseTo(parsed!.colors![3]!, 2); + }); + + it('does not optical-transform map-frame clouds', () => { + const message = buildCloud({ + fields: XYZ_FIELDS, + pointStep: 16, + frameId: 'map', + points: [ + (view, offset) => { + view.setFloat32(offset, 1, true); + view.setFloat32(offset + 4, 2, true); + view.setFloat32(offset + 8, 3, true); + }, + ], + }); + + const parsed = parsePointCloud2(message, { topic: '/map/points', frameId: 'map' }); + expect(Array.from(parsed!.positions)).toEqual([1, 2, 3]); + }); + + it('drops non-finite and non-positive optical depth', () => { + const message = buildCloud({ + fields: XYZ_FIELDS, + pointStep: 16, + frameId: 'camera_optical_frame', + points: [ + (view, offset) => { + view.setFloat32(offset, Number.NaN, true); + view.setFloat32(offset + 4, 0, true); + view.setFloat32(offset + 8, 1, true); + }, + (view, offset) => { + view.setFloat32(offset, 0, true); + view.setFloat32(offset + 4, 0, true); + view.setFloat32(offset + 8, 0, true); // invalid depth + }, + (view, offset) => { + view.setFloat32(offset, 0, true); + view.setFloat32(offset + 4, 0, true); + view.setFloat32(offset + 8, 2, true); + }, + ], + }); + + const parsed = parsePointCloud2(message, { frameId: 'camera_optical_frame' }); + expect(parsed!.count).toBe(1); + expect(parsed!.positions[0]).toBeCloseTo(2); + }); + + it('parses PCL packed rgb float32 field', () => { + const rgbFloat = packPclRgb(255, 128, 0); + const message = buildCloud({ + fields: [ + ...XYZ_FIELDS, + { name: 'rgb', offset: 12, datatype: PointFieldDatatype.FLOAT32 }, + ], + pointStep: 16, + points: [ + (view, offset) => { + view.setFloat32(offset, 0, true); + view.setFloat32(offset + 4, 0, true); + view.setFloat32(offset + 8, 1, true); + view.setFloat32(offset + 12, rgbFloat, true); + }, + ], + }); + + const parsed = parsePointCloud2(message, { frameId: 'map' }); + expect(parsed?.colors).toBeDefined(); + expect(parsed!.colors![0]).toBeCloseTo(1); + expect(parsed!.colors![1]).toBeCloseTo(128 / 255); + expect(parsed!.colors![2]).toBeCloseTo(0); + }); + + it('parses separate r/g/b uint8 fields', () => { + const message = buildCloud({ + fields: [ + ...XYZ_FIELDS, + { name: 'r', offset: 12, datatype: PointFieldDatatype.UINT8 }, + { name: 'g', offset: 13, datatype: PointFieldDatatype.UINT8 }, + { name: 'b', offset: 14, datatype: PointFieldDatatype.UINT8 }, + ], + pointStep: 16, + points: [ + (view, offset) => { + view.setFloat32(offset, 0, true); + view.setFloat32(offset + 4, 0, true); + view.setFloat32(offset + 8, 1, true); + view.setUint8(offset + 12, 0); + view.setUint8(offset + 13, 255); + view.setUint8(offset + 14, 127); + }, + ], + }); + + const parsed = parsePointCloud2(message, { frameId: 'map' }); + expect(parsed?.colors).toBeDefined(); + expect(parsed!.colors![0]).toBeCloseTo(0); + expect(parsed!.colors![1]).toBeCloseTo(1); + expect(parsed!.colors![2]).toBeCloseTo(127 / 255); + }); + + it('parses intensity as per-frame normalized grayscale', () => { + const message = buildCloud({ + fields: [ + ...XYZ_FIELDS, + { name: 'intensity', offset: 12, datatype: PointFieldDatatype.FLOAT32 }, + ], + pointStep: 16, + points: [ + (view, offset) => { + view.setFloat32(offset, 0, true); + view.setFloat32(offset + 4, 0, true); + view.setFloat32(offset + 8, 1, true); + view.setFloat32(offset + 12, 10, true); + }, + (view, offset) => { + view.setFloat32(offset, 1, true); + view.setFloat32(offset + 4, 0, true); + view.setFloat32(offset + 8, 1, true); + view.setFloat32(offset + 12, 30, true); + }, + ], + }); + + const parsed = parsePointCloud2(message, { frameId: 'map' }); + expect(parsed?.colors).toBeDefined(); + expect(parsed!.colors![0]).toBeCloseTo(0); + expect(parsed!.colors![3]).toBeCloseTo(1); + }); + + it('returns null for incomplete messages', () => { + expect(parsePointCloud2(null)).toBeNull(); + expect(parsePointCloud2({})).toBeNull(); + }); +}); + +describe('sampleTurbo', () => { + it('writes distinct colors at 0 and 1', () => { + const a = new Float32Array(3); + const b = new Float32Array(3); + sampleTurbo(0, a, 0); + sampleTurbo(1, b, 0); + expect(a[0]).not.toBeCloseTo(b[0]!, 2); + }); +}); diff --git a/src/shared/utils/pointCloud.ts b/src/shared/utils/pointCloud.ts index 6d3c72b..7e1a1ac 100644 --- a/src/shared/utils/pointCloud.ts +++ b/src/shared/utils/pointCloud.ts @@ -1,14 +1,36 @@ export interface PointCloudData { + /** Densely packed valid points (length === count * 3). */ positions: Float32Array; colors?: Float32Array; + /** Number of valid points in `positions` / `colors`. */ + count: number; + /** + * Preferred GPU buffer capacity (usually width*height). Lets draw-range + * reuse survive per-frame valid-count jitter on sparse depth clouds. + */ + maxPoints?: number; } +export type ParsePointCloud2Options = { + /** Topic name — used with frame_id to decide optical-frame treatment. */ + topic?: string; + /** `header.frame_id` when available. */ + frameId?: string; +}; + interface PointField { name: string; offset: number; datatype?: number; } +/** sensor_msgs/PointField datatype constants. */ +const POINT_FIELD_UINT8 = 2; +const POINT_FIELD_FLOAT32 = 7; + +/** Depth <= this (meters) is treated as invalid in optical clouds. */ +const OPTICAL_MIN_DEPTH_M = 1e-4; + function isPointField(value: unknown): value is PointField { if (!value || typeof value !== "object") return false; const o = value as Record; @@ -19,7 +41,117 @@ function isUint8Array(value: unknown): value is Uint8Array { return value instanceof Uint8Array; } -export function parsePointCloud2(message: unknown): PointCloudData | null { +function fieldByName(fields: PointField[], name: string): PointField | undefined { + return fields.find((f) => f.name === name); +} + +function readPackedRgb( + view: DataView, + offset: number, + littleEndian: boolean, +): [number, number, number] { + const packed = view.getUint32(offset, littleEndian); + const r = ((packed >> 16) & 0xff) / 255; + const g = ((packed >> 8) & 0xff) / 255; + const b = (packed & 0xff) / 255; + return [r, g, b]; +} + +function readNumericField( + view: DataView, + offset: number, + datatype: number | undefined, + littleEndian: boolean, +): number { + if (datatype === POINT_FIELD_UINT8) { + return view.getUint8(offset); + } + return view.getFloat32(offset, littleEndian); +} + +/** REP-103: optical (Z forward, Y down) → ROS (X forward, Z up). */ +export function opticalToRos(ox: number, oy: number, oz: number): [number, number, number] { + return [oz, -ox, -oy]; +} + +/** + * Treat as camera optical frame when frame_id says so (REP-103), or when the + * topic looks like a depth PointCloud2 (`…/depth/…/points`). Map/base_link + * clouds are left unchanged. + */ +export function shouldTreatAsOpticalFrame(frameId?: string, topic?: string): boolean { + if (frameId && /optical/i.test(frameId)) { + return true; + } + if (topic && /(?:^|\/)depth(?:\/|$)/i.test(topic) && /points/i.test(topic)) { + return true; + } + return false; +} + +export function readPointCloudFrameId(message: Record): string | undefined { + const header = message.header; + if (!header || typeof header !== "object") return undefined; + const frameId = (header as Record).frame_id; + return typeof frameId === "string" ? frameId : undefined; +} + +// ── Turbo (same polynomial as Image panel) ─────────────────────────────────── + +const kRedVec4 = [0.13572138, 4.6153926, -42.66032258, 132.13108234] as const; +const kGreenVec4 = [0.09140261, 2.19418839, 4.84296658, -14.18503333] as const; +const kBlueVec4 = [0.1066733, 12.64194608, -60.58204836, 110.36276771] as const; +const kRedVec2 = [-152.94239396, 59.28637943] as const; +const kGreenVec2 = [4.27729857, 2.82956604] as const; +const kBlueVec2 = [-89.90310912, 27.34824973] as const; + +function clamp01(x: number): number { + return Math.max(0, Math.min(1, x)); +} + +export function sampleTurbo(pct: number, out: Float32Array, outOffset: number): void { + const x = clamp01(pct) * 0.99 + 0.01; + const x2 = x * x; + const x3 = x2 * x; + const x4 = x2 * x2; + const x5 = x3 * x2; + const r = + kRedVec4[0] + + x * kRedVec4[1] + + x2 * kRedVec4[2] + + x3 * kRedVec4[3] + + x4 * kRedVec2[0] + + x5 * kRedVec2[1]; + const g = + kGreenVec4[0] + + x * kGreenVec4[1] + + x2 * kGreenVec4[2] + + x3 * kGreenVec4[3] + + x4 * kGreenVec2[0] + + x5 * kGreenVec2[1]; + const b = + kBlueVec4[0] + + x * kBlueVec4[1] + + x2 * kBlueVec4[2] + + x3 * kBlueVec4[3] + + x4 * kBlueVec2[0] + + x5 * kBlueVec2[1]; + out[outOffset] = clamp01(r); + out[outOffset + 1] = clamp01(g); + out[outOffset + 2] = clamp01(b); +} + +/** + * Parse a `sensor_msgs/PointCloud2` message into GPU-friendly typed arrays. + * + * - Drops non-finite xyz; in optical mode also drops depth <= 0. + * - Optical frames (heuristic) are converted to ROS Z-up. + * - Color priority: rgb/rgba → r/g/b → intensity → depth/range turbo. + */ +export function parsePointCloud2( + message: unknown, + options: ParsePointCloud2Options = {}, +): PointCloudData | null { if (!message || typeof message !== "object") return null; const m = message as Record; const { fields, data, point_step, width, height } = m; @@ -27,24 +159,179 @@ export function parsePointCloud2(message: unknown): PointCloudData | null { if (typeof width !== "number" || typeof height !== "number") return null; const count = width * height; - const positions = new Float32Array(count * 3); + if (count <= 0 || point_step <= 0) return null; + if (data.byteLength < count * point_step) return null; - const typedFields = fields.filter(isPointField); - const xField = typedFields.find((f) => f.name === "x"); - const yField = typedFields.find((f) => f.name === "y"); - const zField = typedFields.find((f) => f.name === "z"); + const frameId = options.frameId ?? readPointCloudFrameId(m); + const asOptical = shouldTreatAsOpticalFrame(frameId, options.topic); + const typedFields = fields.filter(isPointField); + const xField = fieldByName(typedFields, "x"); + const yField = fieldByName(typedFields, "y"); + const zField = fieldByName(typedFields, "z"); if (!xField || !yField || !zField) return null; + const littleEndian = m.is_bigendian !== true; const view = new DataView(data.buffer, data.byteOffset, data.byteLength); - const isLittleEndian = true; // ROS2 is usually little endian + + const rgbField = fieldByName(typedFields, "rgb") ?? fieldByName(typedFields, "rgba"); + const rField = fieldByName(typedFields, "r"); + const gField = fieldByName(typedFields, "g"); + const bField = fieldByName(typedFields, "b"); + const hasRgbChannels = !!(rField && gField && bField); + const intensityField = fieldByName(typedFields, "intensity"); + + const colorMode: "rgb" | "rgba_fields" | "intensity" | "depth" = rgbField + ? "rgb" + : hasRgbChannels + ? "rgba_fields" + : intensityField + ? "intensity" + : "depth"; + + const tmpPositions = new Float32Array(count * 3); + const tmpColors = new Float32Array(count * 3); + const tmpScalar = colorMode === "rgb" || colorMode === "rgba_fields" ? null : new Float32Array(count); + + const xyzContiguous = + littleEndian && + point_step % 4 === 0 && + xField.offset === 0 && + yField.offset === 4 && + zField.offset === 8 && + (xField.datatype === undefined || xField.datatype === POINT_FIELD_FLOAT32) && + (yField.datatype === undefined || yField.datatype === POINT_FIELD_FLOAT32) && + (zField.datatype === undefined || zField.datatype === POINT_FIELD_FLOAT32); + + const srcFloats = xyzContiguous + ? new Float32Array(data.buffer, data.byteOffset, Math.floor(data.byteLength / 4)) + : null; + const floatsPerPoint = point_step / 4; + + let written = 0; + let scalarMin = Number.POSITIVE_INFINITY; + let scalarMax = Number.NEGATIVE_INFINITY; for (let i = 0; i < count; i++) { - const offset = i * point_step; - positions[i * 3] = view.getFloat32(offset + xField.offset, isLittleEndian); - positions[i * 3 + 1] = view.getFloat32(offset + yField.offset, isLittleEndian); - positions[i * 3 + 2] = view.getFloat32(offset + zField.offset, isLittleEndian); + let ox: number; + let oy: number; + let oz: number; + if (srcFloats) { + const s = i * floatsPerPoint; + ox = srcFloats[s]!; + oy = srcFloats[s + 1]!; + oz = srcFloats[s + 2]!; + } else { + const offset = i * point_step; + ox = view.getFloat32(offset + xField.offset, littleEndian); + oy = view.getFloat32(offset + yField.offset, littleEndian); + oz = view.getFloat32(offset + zField.offset, littleEndian); + } + + if (!Number.isFinite(ox) || !Number.isFinite(oy) || !Number.isFinite(oz)) { + continue; + } + // Optical depth clouds commonly encode invalid pixels as z<=0. + if (asOptical && oz <= OPTICAL_MIN_DEPTH_M) { + continue; + } + + let px: number; + let py: number; + let pz: number; + if (asOptical) { + [px, py, pz] = opticalToRos(ox, oy, oz); + } else { + px = ox; + py = oy; + pz = oz; + } + + const d = written * 3; + tmpPositions[d] = px; + tmpPositions[d + 1] = py; + tmpPositions[d + 2] = pz; + + if (colorMode === "rgb" && rgbField) { + const [cr, cg, cb] = readPackedRgb(view, i * point_step + rgbField.offset, littleEndian); + tmpColors[d] = cr; + tmpColors[d + 1] = cg; + tmpColors[d + 2] = cb; + } else if (colorMode === "rgba_fields" && rField && gField && bField) { + const base = i * point_step; + tmpColors[d] = view.getUint8(base + rField.offset) / 255; + tmpColors[d + 1] = view.getUint8(base + gField.offset) / 255; + tmpColors[d + 2] = view.getUint8(base + bField.offset) / 255; + } else if (tmpScalar) { + const value = + colorMode === "intensity" && intensityField + ? readNumericField( + view, + i * point_step + intensityField.offset, + intensityField.datatype, + littleEndian, + ) + : asOptical + ? oz + : Math.hypot(ox, oy, oz); + tmpScalar[written] = value; + if (Number.isFinite(value)) { + if (value < scalarMin) scalarMin = value; + if (value > scalarMax) scalarMax = value; + } + } + + written++; } - return { positions }; + if (written === 0) { + return { positions: new Float32Array(0), count: 0, maxPoints: count }; + } + + const positions = tmpPositions.slice(0, written * 3); + let colors: Float32Array | undefined; + + if (colorMode === "rgb" || colorMode === "rgba_fields") { + colors = tmpColors.slice(0, written * 3); + } else if (tmpScalar) { + colors = new Float32Array(written * 3); + const range = scalarMax - scalarMin; + if (colorMode === "intensity") { + if (range <= 0 || !Number.isFinite(range)) { + colors.fill(0.5); + } else { + for (let i = 0; i < written; i++) { + const t = (tmpScalar[i]! - scalarMin) / range; + const o = i * 3; + colors[o] = t; + colors[o + 1] = t; + colors[o + 2] = t; + } + } + } else if (range <= 0 || !Number.isFinite(range)) { + for (let i = 0; i < written; i++) { + sampleTurbo(0.5, colors, i * 3); + } + } else { + for (let i = 0; i < written; i++) { + sampleTurbo((tmpScalar[i]! - scalarMin) / range, colors, i * 3); + } + } + } + + return colors + ? { positions, colors, count: written, maxPoints: count } + : { positions, count: written, maxPoints: count }; } + +/** Copy bytes into a plain ArrayBuffer safe to put on a Worker transfer list. */ +export function copyToTransferableArrayBuffer(data: Uint8Array): ArrayBuffer { + const payload = new ArrayBuffer(data.byteLength); + new Uint8Array(payload).set(data); + return payload; +} + +export const PointFieldDatatype = { + UINT8: POINT_FIELD_UINT8, + FLOAT32: POINT_FIELD_FLOAT32, +} as const;