diff --git a/packages/Debug/src/PointCloudDebug.js b/packages/Debug/src/PointCloudDebug.js index cce114ab5f..17217a1bb0 100644 --- a/packages/Debug/src/PointCloudDebug.js +++ b/packages/Debug/src/PointCloudDebug.js @@ -40,10 +40,12 @@ function setupControllerVisibily(gui, displayMode, sizeMode) { } sizeMode = parseInt(sizeMode, 10); - if (sizeMode === PNTS_SIZE_MODE.VALUE) { + if ([PNTS_SIZE_MODE.VALUE, PNTS_SIZE_MODE.ADAPTIVE].includes(sizeMode)) { + getController(gui, 'pointSize').show(); getController(gui, 'minAttenuatedSize').hide(); getController(gui, 'maxAttenuatedSize').hide(); } else { + getController(gui, 'pointSize').hide(); getController(gui, 'minAttenuatedSize').show(); getController(gui, 'maxAttenuatedSize').show(); } @@ -297,9 +299,8 @@ export default { } styleUI.add(layer, 'opacity', 0, 1).name('Layer opacity').onChange(update); - styleUI.add(layer, 'pointSize', 0, 15).name('Point size').onChange(update); if (layer.material.sizeMode != undefined && view.camera.camera3D.isPerspectiveCamera) { - styleUI.add(layer.material, 'sizeAttenuation').name('Size attenuation') + styleUI.add(layer.material, 'sizeMode', PNTS_SIZE_MODE).name('Size mode') .onChange(update); styleUI.add(layer.material, 'minAttenuatedSize', 0, 15).name('Min size') .onChange((value) => { @@ -318,6 +319,7 @@ export default { update(); }); } + styleUI.add(layer, 'pointSize', 0, 15).name('Point size').onChange(update); if (layer.material.picking != undefined) { styleUI.add(layer.material, 'picking').name('Display picking id').onChange(update); diff --git a/packages/Main/src/Core/PointCloudNode.ts b/packages/Main/src/Core/PointCloudNode.ts index 6666544584..02c8aa37b6 100644 --- a/packages/Main/src/Core/PointCloudNode.ts +++ b/packages/Main/src/Core/PointCloudNode.ts @@ -123,14 +123,18 @@ abstract class PointCloudNode extends THREE.EventDispatcher { const y = this.y * 2; const z = this.z * 2; - this.findAndCreateChild(depth, x, y, z); - this.findAndCreateChild(depth, x + 1, y, z); - this.findAndCreateChild(depth, x, y + 1, z); - this.findAndCreateChild(depth, x + 1, y + 1, z); - this.findAndCreateChild(depth, x, y, z + 1); - this.findAndCreateChild(depth, x + 1, y, z + 1); - this.findAndCreateChild(depth, x, y + 1, z + 1); - this.findAndCreateChild(depth, x + 1, y + 1, z + 1); + // Order follows ascending Potree child index (4*dx + 2*dy + dz), + // i.e. 0→7, so that node.children is always in Potree index order. + // This is required by the visibility texture encoding used for + // adaptive point-size rendering in the shader. + this.findAndCreateChild(depth, x, y, z); // 0 + this.findAndCreateChild(depth, x, y, z + 1); // 1 + this.findAndCreateChild(depth, x, y + 1, z); // 2 + this.findAndCreateChild(depth, x, y + 1, z + 1); // 3 + this.findAndCreateChild(depth, x + 1, y, z); // 4 + this.findAndCreateChild(depth, x + 1, y, z + 1); // 5 + this.findAndCreateChild(depth, x + 1, y + 1, z); // 6 + this.findAndCreateChild(depth, x + 1, y + 1, z + 1); // 7 this._childrenCreated = true; } diff --git a/packages/Main/src/Layer/C3DTilesLayer.js b/packages/Main/src/Layer/C3DTilesLayer.js index 05a1ccc406..64b227b0fb 100644 --- a/packages/Main/src/Layer/C3DTilesLayer.js +++ b/packages/Main/src/Layer/C3DTilesLayer.js @@ -99,7 +99,10 @@ class C3DTilesLayer extends GeometryLayer { * Only 'COLOR' or 'CLASSIFICATION' are possible. COLOR uses RGB colors of the points, * CLASSIFICATION uses a classification property of the batch table to color points. * @param {string} [config.pntsShape= PNTS_SHAPE.CIRCLE] Point cloud point shape. Only 'CIRCLE' or 'SQUARE' are possible. - * @param {string} [config.pntsSizeMode= PNTS_SIZE_MODE.VALUE] {@link PointsMaterial} Point cloud size mode. Only 'VALUE' or 'ATTENUATED' are possible. VALUE use constant size, ATTENUATED compute size depending on distance from point to camera. + * @param {string} [config.pntsSizeMode= PNTS_SIZE_MODE.VALUE] {@link PointsMaterial} Point cloud size mode. + * Only 'VALUE' or 'ATTENUATED' or 'ADAPTIVE' are possible. VALUE use constant size, ATTENUATED compute size depending on distance + * from point to camera and ADAPTIVE computes the point size on a per-point basis rather than on a per-node basis. + * **Warning:** ADAPTIVE work only if point cloud is an octree * @param {number} [config.pntsMinAttenuatedSize=3] Minimum scale used by 'ATTENUATED' size mode * @param {number} [config.pntsMaxAttenuatedSize=10] Maximum scale used by 'ATTENUATED' size mode * @param {Style} [config.style=null] - style used for this layer diff --git a/packages/Main/src/Layer/OGC3DTilesLayer.js b/packages/Main/src/Layer/OGC3DTilesLayer.js index 50ca7696fb..c0f7775cd8 100644 --- a/packages/Main/src/Layer/OGC3DTilesLayer.js +++ b/packages/Main/src/Layer/OGC3DTilesLayer.js @@ -370,8 +370,9 @@ class OGC3DTilesLayer extends GeometryLayer { * @param {string} [config.pntsShape = PNTS_SHAPE.CIRCLE] Point cloud point shape. Only 'CIRCLE' or 'SQUARE' are possible. * (passed to {@link PointsMaterial}). * @param {string} [config.pntsSizeMode = PNTS_SIZE_MODE.VALUE] {@link PointsMaterial} Point cloud size mode (passed to {@link PointsMaterial}). - * Only 'VALUE' or 'ATTENUATED' are possible. VALUE use constant size, ATTENUATED compute size depending on distance - * from point to camera. + * Only 'VALUE' or 'ATTENUATED' or 'ADAPTIVE' are possible. VALUE use constant size, ATTENUATED compute size depending on distance + * from point to camera and ADAPTIVE computes the point size on a per-point basis rather than on a per-node basis. + * **Warning:** ADAPTIVE work only if point cloud is an octree * @param {number} [config.pntsMinAttenuatedSize = 3] Minimum scale used by 'ATTENUATED' size mode. * @param {number} [config.pntsMaxAttenuatedSize = 10] Maximum scale used by 'ATTENUATED' size mode. */ diff --git a/packages/Main/src/Layer/PointCloudLayer.ts b/packages/Main/src/Layer/PointCloudLayer.ts index d1e9ae8959..e594aae7df 100644 --- a/packages/Main/src/Layer/PointCloudLayer.ts +++ b/packages/Main/src/Layer/PointCloudLayer.ts @@ -1,8 +1,9 @@ import * as THREE from 'three'; import TinyQueue from 'tinyqueue'; import GeometryLayer from 'Layer/GeometryLayer'; -import PointsMaterial, { PNTS_MODE } from 'Renderer/PointsMaterial'; +import PointsMaterial, { PNTS_MODE, PNTS_SIZE_MODE } from 'Renderer/PointsMaterial'; import Picking from 'Core/Picking'; +import { computeVisibilityTextureData } from 'Utils/PointCloudUtils'; import type PointCloudNode from 'Core/PointCloudNode'; @@ -228,6 +229,8 @@ abstract class PointCloudLayer private _candidateNodes: TinyQueue; private _visibleNodes = new Set(); + private _visibilityTextureNeedsUpdate = true; + private _visibilityTextureData: { data: Uint8Array; nodeToIndex: Map } | undefined; /** * Constructs a new instance of point cloud layer. @@ -333,6 +336,7 @@ abstract class PointCloudLayer node.notVisibleSince = Date.now(); node.sse = -1; } + this._visibilityTextureNeedsUpdate = true; } preUpdate(context: Context) { @@ -535,6 +539,71 @@ abstract class PointCloudLayer } } + // @ts-expect-error PointsMaterial is not typed yet + if (this.root && (this.material.sizeMode === PNTS_SIZE_MODE.ADAPTIVE)) { + if (this._visibilityTextureNeedsUpdate) { + this._visibilityTextureData = + computeVisibilityTextureData(this.root, Array.from(this._visibleNodes)); + this._visibilityTextureNeedsUpdate = false; + } + + if (this._visibilityTextureData === undefined) { + return; + } + + // @ts-expect-error PointsMaterial is not typed yet + const vnt = this.material.visibleNodes; + const data = vnt.image.data; + data.fill(0); + data.set(this._visibilityTextureData.data); + vnt.needsUpdate = true; + + // Use box3D (projected in the local geometry coordinate frame) for + // octreeSize and bboxMin, because the vertex shader positions are + // expressed in that same local frame after CRS reprojection and + // quaternion rotation applied by the parsers. + // Using natBox (native source CRS) would mix coordinate systems + // (e.g. Lambert93 metres vs. ECEF metres) and produce wrong LODs + // for point clouds whose source CRS differs from the scene CRS. + const rootSize = this.root.voxelOBB.box3D.getSize(new THREE.Vector3()); + + for (const pts of this.group.children) { + const node = pts.userData.node; + const depth = node.depth; + const nodeStartOffset = this._visibilityTextureData.nodeToIndex.get(node); + const octreeSpacing = node.source.spacing; + + if (nodeStartOffset === undefined) { + continue; + } + + // Compute the bounding box min of the node's octree cell in + // the local coordinate frame used by the THREE.Points geometry. + // The parsers store each point as: + // quat * (forward(nativePos) - centerZ0) + // where centerZ0 = voxelOBB.position and quat is computed at + // that same position. projOBB() builds box3D with the exact + // same origin and the same quaternion, so box3D.min is already + // expressed in the geometry's local frame — no further + // transformation is needed. + const bboxMin = node.voxelOBB.box3D.min.clone(); + + pts.onBeforeRender = (_renderer, _scene, _camera, _geometry, material) => { + // @ts-expect-error Material is not typed yet + material.uniforms.nodeStartOffset.value = nodeStartOffset; + // @ts-expect-error Material is not typed yet + material.uniforms.octreeSize.value.copy(rootSize); + // @ts-expect-error Material is not typed yet + material.uniforms.octreeSpacing.value = octreeSpacing; + // @ts-expect-error Material is not typed yet + material.uniforms.nodeDepth.value = depth; + // @ts-expect-error Material is not typed yet + material.uniforms.nodeBBoxMin.value.copy(bboxMin); + // @ts-expect-error Material is not typed yet + material.uniformsNeedUpdate = true; + }; + } + } this.dispatchEvent({ type: 'post-update' }); } diff --git a/packages/Main/src/Main.js b/packages/Main/src/Main.js index 5261034d8a..fbe77ce103 100644 --- a/packages/Main/src/Main.js +++ b/packages/Main/src/Main.js @@ -32,6 +32,7 @@ export { default as Feature2Mesh } from 'Converter/Feature2Mesh'; export { default as FeaturesUtils } from 'Utils/FeaturesUtils'; export { default as DEMUtils } from 'Utils/DEMUtils'; export { default as CameraUtils } from 'Utils/CameraUtils'; +export { computeVisibilityTextureData } from 'Utils/PointCloudUtils'; export { default as ShaderChunk } from 'Renderer/Shader/ShaderChunk'; export { getMaxColorSamplerUnitsCount, colorLayerEffects } from 'Renderer/LayeredMaterial'; export { default as Capabilities } from 'Core/System/Capabilities'; diff --git a/packages/Main/src/Renderer/PointsMaterial.js b/packages/Main/src/Renderer/PointsMaterial.js index 05d2c2abff..bbaa20d3ac 100644 --- a/packages/Main/src/Renderer/PointsMaterial.js +++ b/packages/Main/src/Renderer/PointsMaterial.js @@ -25,6 +25,7 @@ export const PNTS_SHAPE = { export const PNTS_SIZE_MODE = { VALUE: 0, ATTENUATED: 1, + ADAPTIVE: 2, }; const white = new THREE.Color(1.0, 1.0, 1.0); @@ -161,7 +162,9 @@ class PointsMaterial extends THREE.ShaderMaterial { * @param {Scheme} [options.discreteScheme] LUT for other discret point values colorization. * @param {string} [options.gradient] Descrition of the gradient to use for continuous point values. * (Default value will be the 'SPECTRAL' gradient from Utils/Gradients) - * @param {number} [options.sizeMode=PNTS_SIZE_MODE.VALUE] point cloud size mode. Only 'VALUE' or 'ATTENUATED' are possible. VALUE use constant size, ATTENUATED compute size depending on distance from point to camera. + * @param {number} [options.sizeMode=PNTS_SIZE_MODE.VALUE] point cloud size mode. Only 'VALUE' or 'ATTENUATED' or 'ADAPTIVE' are possible. + * VALUE use constant size, ATTENUATED compute size depending on distance from point to camera and ADAPTIVE computes the point size on a per-point basis rather than on a per-node basis. + * **Warning:** ADAPTIVE work only if point cloud is an octree * @param {number} [options.minAttenuatedSize=3] minimum scale used by 'ATTENUATED' size mode * @param {number} [options.maxAttenuatedSize=10] maximum scale used by 'ATTENUATED' size mode * @@ -245,6 +248,13 @@ class PointsMaterial extends THREE.ShaderMaterial { CommonMaterial.setUniformProperty(this, 'gamma', gamma); CommonMaterial.setUniformProperty(this, 'ambientBoost', ambientBoost); + // Adaptive point size uniforms + CommonMaterial.setUniformProperty(this, 'octreeSpacing', 1.0); + CommonMaterial.setUniformProperty(this, 'octreeSize', new THREE.Vector3()); + CommonMaterial.setUniformProperty(this, 'nodeDepth', 0.0); + CommonMaterial.setUniformProperty(this, 'nodeStartOffset', 0.0); + CommonMaterial.setUniformProperty(this, 'nodeBBoxMin', new THREE.Vector3()); + // add classification texture to apply classification lut. const data = new Uint8Array(256 * 4); const texture = new THREE.DataTexture(data, 256, 1, THREE.RGBAFormat); @@ -267,6 +277,12 @@ class PointsMaterial extends THREE.ShaderMaterial { textureVisi.magFilter = THREE.NearestFilter; CommonMaterial.setUniformProperty(this, 'visibilityTexture', textureVisi); + const dataNodes = new Uint8Array(2048 * 4); + const visibleNodesTexture = new THREE.DataTexture(dataNodes, 2048, 1, THREE.RGBAFormat); + visibleNodesTexture.needsUpdate = true; + visibleNodesTexture.magFilter = THREE.NearestFilter; + CommonMaterial.setUniformProperty(this, 'visibleNodes', visibleNodesTexture); + // Classification and other discrete values scheme this.classificationScheme = classificationScheme; this.discreteScheme = discreteScheme; diff --git a/packages/Main/src/Renderer/Shader/PointsVS.glsl b/packages/Main/src/Renderer/Shader/PointsVS.glsl index 3b272b431e..afe02b999b 100644 --- a/packages/Main/src/Renderer/Shader/PointsVS.glsl +++ b/packages/Main/src/Renderer/Shader/PointsVS.glsl @@ -31,6 +31,14 @@ uniform int sizeMode; uniform float minAttenuatedSize; uniform float maxAttenuatedSize; +// Adaptive point size uniforms +uniform sampler2D visibleNodes; +uniform float octreeSpacing; +uniform vec3 octreeSize; +uniform float nodeDepth; +uniform float nodeStartOffset; +uniform vec3 nodeBBoxMin; + attribute vec4 unique_id; attribute float intensity; attribute float classification; @@ -40,6 +48,61 @@ attribute float returnNumber; attribute float numberOfReturns; attribute float scanAngle; +// Adaptive point size calculation functions (from Potree) +/** + * number of 1-bits up to inclusive index position + * number is treated as if it were an integer in the range 0-255 + * + */ +int numberOfOnes(int number, int index) { + int numOnes = 0; + int tmp = 128; + for (int i = 7; i >= 0; i--) { + if (number >= tmp) { + number = number - tmp; + if (i <= index) { + numOnes++; + } + } + tmp = tmp / 2; + } + return numOnes; +} + +float getLOD() { + // Transform position from local space (relative to node origin) + // to octree space (relative to node bbox min) + vec3 pos = position - nodeBBoxMin; + vec3 offset = vec3(0.0, 0.0, 0.0); + int iOffset = int(nodeStartOffset); + float depth = nodeDepth; + for (float i = 0.0; i <= 30.0; i++) { + vec3 nodeSizeAtLevel = octreeSize / pow(2.0, i + nodeDepth); + + vec3 index3d = (pos - offset) / nodeSizeAtLevel; + index3d = clamp(floor(index3d + 0.5), 0.0, 1.0); + int index = int(round(4.0 * index3d.x + 2.0 * index3d.y + index3d.z)); + + vec4 value = texture2D(visibleNodes, vec2((float(iOffset) + 0.5) / 2048.0, 0.0)); + int mask = int(round(value.r * 255.0)); + bool childNodeExist = bool(((mask >> index) & 1) != 0); + + if (childNodeExist) { + int greenChannelOffset = int(round(value.g * 255.0)) * 256; + int blueChannelOffset = int(round(value.b * 255.0)); + int childIndexOffset = numberOfOnes(mask, index - 1); + int totalOffset = greenChannelOffset + blueChannelOffset + childIndexOffset; + iOffset = iOffset + totalOffset; + depth++; + } else { + // no more visible child nodes at this position + return depth; + } + offset = offset + (vec3(1.0, 1.0, 1.0) * nodeSizeAtLevel * 0.5) * index3d; + } + return depth; +} + void main() { vec2 uv = vec2(classification/255., 0.5); @@ -117,13 +180,21 @@ void main() { gl_PointSize = size; - if (sizeMode == PNTS_SIZE_MODE_ATTENUATED) { - bool isPerspective = isPerspectiveMatrix(projectionMatrix); + bool isPerspective = isPerspectiveMatrix(projectionMatrix); + float depthAttenuationFactor = scale / -mvPosition.z; + if (sizeMode == PNTS_SIZE_MODE_ATTENUATED) { if (isPerspective) { - gl_PointSize *= scale / -mvPosition.z; + gl_PointSize *= depthAttenuationFactor; gl_PointSize = clamp(gl_PointSize, minAttenuatedSize, maxAttenuatedSize); } + } else if (sizeMode == PNTS_SIZE_MODE_ADAPTIVE) { + if (isPerspective) { + float r = octreeSpacing * 1.7; + float pointSizeAttenuation = pow(2.0, getLOD()); + float worldSpaceSize = size * r / pointSizeAttenuation; + gl_PointSize = worldSpaceSize * depthAttenuationFactor; + } } #include diff --git a/packages/Main/src/Utils/PointCloudUtils.ts b/packages/Main/src/Utils/PointCloudUtils.ts new file mode 100644 index 0000000000..dbd6147fd7 --- /dev/null +++ b/packages/Main/src/Utils/PointCloudUtils.ts @@ -0,0 +1,83 @@ +import type PointCloudNode from '../Core/PointCloudNode'; + +// Returns the Potree child index (0-7) of a node relative to its parent +// based on coordinates (convention: 4*dx + 2*dy + dz) +function getPotreeChildIndex(parent: PointCloudNode, child: PointCloudNode): number { + const dx = child.x - parent.x * 2; + const dy = child.y - parent.y * 2; + const dz = child.z - parent.z * 2; + return 4 * dx + 2 * dy + dz; +} + +function encodeChildrenVisibility(node: PointCloudNode, nodeIndex: number, + nodeToIndex: Map): { minOffset: number; childVisibilityMask: number } { + let minOffset = Infinity; + let childVisibilityMask = 0; + + for (const child of node.children) { + const childIndex = nodeToIndex.get(child); + if (childIndex === undefined) { + continue; + } + + childVisibilityMask |= (1 << getPotreeChildIndex(node, child)); + + const offset = childIndex - nodeIndex; + if (offset < minOffset) { + minOffset = offset; + } + } + + return { + minOffset, + childVisibilityMask, + }; +} + +// Encoding the octree hierarchy in breadth-first order +// into a texture for adaptive point size rendering +// Explanation p36: https://www.cg.tuwien.ac.at/research/publications/2016/SCHUETZ-2016-POT/SCHUETZ-2016-POT-thesis.pdf +export function computeVisibilityTextureData(root: PointCloudNode, visibleNodes: PointCloudNode[]) { + const visibleSet = new Set(visibleNodes); + + const nodeToIndex = new Map(); + const queue: PointCloudNode[] = [root]; + let index = 0; + while (queue.length > 0) { + const node = queue.shift() as PointCloudNode; + if (!visibleSet.has(node)) { + continue; + } + nodeToIndex.set(node, index++); + for (const child of node.children) { + queue.push(child); + } + } + + const data = new Uint8Array(visibleNodes.length * 4); + + for (const [node, nodeIndex] of nodeToIndex) { + const { minOffset, childVisibilityMask } = + encodeChildrenVisibility(node, nodeIndex, nodeToIndex); + + if (minOffset === Infinity) { + continue; + } + + // Red channel: bitmask of which children are visible + data[nodeIndex * 4] = childVisibilityMask; + // Offset to child is stored on 2 bytes, + // so it can support up to 65536 nodes per subtree. + // The green channel contains the relative offset + // to the node's first child (8 most significant bits) + data[nodeIndex * 4 + 1] = minOffset >> 8; + // The blue channel contains the relative offset + // to the node's first child (8 least significant bits) + data[nodeIndex * 4 + 2] = minOffset % 256; + } + + return { + data, + nodeToIndex, + }; +} diff --git a/packages/Main/test/unit/adaptivePointSize.js b/packages/Main/test/unit/adaptivePointSize.js new file mode 100644 index 0000000000..1103307a94 --- /dev/null +++ b/packages/Main/test/unit/adaptivePointSize.js @@ -0,0 +1,122 @@ +import assert from 'assert'; +import PointCloudNode, { buildVoxelKey } from 'Core/PointCloudNode'; +import { computeVisibilityTextureData } from 'Utils/PointCloudUtils'; + +function createNode(depth, x, y, z) { + const node = new PointCloudNode(depth); + node.x = x; + node.y = y; + node.z = z; + node.voxelKey = buildVoxelKey(depth, x, y, z); + // Override setOBBes so node.add() doesn't require a real source/crs. + node.setOBBes = () => {}; + return node; +} + +// Counts the number of set bits in `mask` at positions 0..maxIndex (inclusive). +// Mirrors the GLSL helper numberOfOnes() used in PointsVS.glsl. +function numberOfOnes(mask, maxIndex) { + let count = 0; + for (let i = 0; i <= maxIndex; i++) { + if ((mask >> i) & 1) { count++; } + } + return count; +} + +describe('Adaptive point size', function () { + describe('computeVisibilityTextureData', function () { + it('Single visible root → nodeToIndex has one entry', function () { + const root = createNode(0, 0, 0, 0); + + const { data, nodeToIndex } = computeVisibilityTextureData(root, [root]); + + // BFS should have assigned index 0 to the root. + assert.strictEqual(nodeToIndex.size, 1); + assert.strictEqual(nodeToIndex.get(root), 0); + + // Output buffer: 1 node × 4 channels. + assert.strictEqual(data.length, 4); + + // No visible children → all channels 0. + assert.strictEqual(data[0], 0, 'R channel (child mask) should be 0'); + assert.strictEqual(data[1], 0, 'G channel (offset high byte) should be 0'); + assert.strictEqual(data[2], 0, 'B channel (offset low byte) should be 0'); + }); + + it('3-level tree → correct BFS indices, mask and offset', function () { + const root = createNode(0, 0, 0, 0); + // childA : potree index 0 (dx=0, dy=0, dz=0) + const childA = createNode(1, 0, 0, 0); + // childB : potree index 4 (dx=1, dy=0, dz=0) + const childB = createNode(1, 1, 0, 0); + // grandChildren of childA (potree indices 0 and 1 relative to childA) + const childAA = createNode(2, 0, 0, 0); + const childAB = createNode(2, 0, 0, 1); + + root.add(childA); + root.add(childB); + childA.add(childAA); + childA.add(childAB); + + const visibleNodes = [root, childA, childAA, childAB, childB]; + const { data, nodeToIndex } = computeVisibilityTextureData(root, visibleNodes); + + // BFS assigns root=0, childA=1, childB=2. + assert.equal(nodeToIndex.get(root), 0); + assert.equal(nodeToIndex.get(childA), 1); + assert.equal(nodeToIndex.get(childB), 2); + assert.equal(nodeToIndex.get(childAA), 3, 'childAA BFS index'); + assert.equal(nodeToIndex.get(childAB), 4, 'childAB BFS index'); + + // Root encoding: + // mask = (1 << 0) | (1 << 4) = 1 + 16 = 17 + // minOffset = min(1-0, 2-0) = 1 + assert.equal(data[0], 17, 'root R channel (child mask)'); + assert.equal(data[1], 0, 'root G channel (offset high byte)'); + assert.equal(data[2], 1, 'root B channel (offset low byte = 1)'); + + // childA: both childAA (potree idx 0) and childAB (potree idx 1) are visible. + // mask = (1 << 0) | (1 << 1) = 3 + // minOffset = min(3-1, 4-1) = 2 + assert.equal(data[4], 3, 'childA R channel (mask for childAA and childAB)'); + assert.equal(data[5], 0, 'childA G channel (offset high byte)'); + assert.equal(data[6], 2, 'childA B channel (offset low byte = 2)'); + + // childB has no visible sub-children → its channels remain 0. + assert.equal(data[8], 0, 'childB R channel'); + + // grandChildren have no visible sub-children. + assert.strictEqual(data[12], 0, 'childAA R channel'); + assert.strictEqual(data[16], 0, 'childAB R channel'); + + // --- Octree reconstruction from texture data --- + // Invert nodeToIndex to get indexToNode + const indexToNode = new Map(); + for (const [node, idx] of nodeToIndex) { + indexToNode.set(idx, node); + } + + // For each visible node, decode its children list from the texture + const reconstructed = new Map(); + for (const [node, nodeIdx] of nodeToIndex) { + const mask = data[nodeIdx * 4]; + const minOffset = (data[nodeIdx * 4 + 1] << 8) | data[nodeIdx * 4 + 2]; + const children = []; + for (let bit = 0; bit < 8; bit++) { + if ((mask >> bit) & 1) { + const childOffset = numberOfOnes(mask, bit - 1); + const childIdx = nodeIdx + minOffset + childOffset; + children.push(indexToNode.get(childIdx)); + } + } + reconstructed.set(node, children); + } + + assert.deepStrictEqual(reconstructed.get(root), [childA, childB], 'reconstructed root children'); + assert.deepStrictEqual(reconstructed.get(childA), [childAA, childAB], 'reconstructed childA children'); + assert.deepStrictEqual(reconstructed.get(childB), [], 'reconstructed childB has no children'); + assert.deepStrictEqual(reconstructed.get(childAA), [], 'reconstructed childAA has no children'); + assert.deepStrictEqual(reconstructed.get(childAB), [], 'reconstructed childAB has no children'); + }); + }); +});