Repository: NASA-AMMOS/3DTilesRendererJS (js)

Issue:
[PNTSLoader] Unsupported features with custom points material
Hi!

We are currently utilizing the 3d-tiles-renderer with a custom points material which could support both `NORMAL`/`NORMAL_OCT16P` per-point feature (as a standard `normal` attribute), and `BATCH_LENGTH` global feature.

I understand that the default `PointsMaterial` in three does not currently support `normal` attributes. However, I would like to propose adding support for loading `NORMAL`/`NORMAL_OCT16P`, in line with three semantics.

Moreover, I suspect that since `BATCH_LENGTH` as no equivalent in the default `PointsMaterial`, we'll have to assign it later when using our custom points material. However, could we at least disable the warnings to not flood the console?

Before submitting a PR, I would appreciate your feedback on this proposal. =)


Pull request description:
feat(PNTSLoader): support NORMAL and NORMAL_OCT16P semantics (#594)
This PR adds support for `NORMAL` and `NORMAL_OCT16P` (octahedron-encoded normals) point semantics to the `PNTSLoader`.

Resources:
- <https://knarkowicz.wordpress.com/2014/04/16/octahedron-normal-vector-encoding/>
- <https://stackoverflow.com/a/74745666/2704779>

fix #594 

Patch:
diff --git a/src/three/loaders/PNTSLoader.js b/src/three/loaders/PNTSLoader.js
index b9cd131..d96b58b 100644
--- a/src/three/loaders/PNTSLoader.js
+++ b/src/three/loaders/PNTSLoader.js
@@ -9,6 +9,7 @@ import {
 	Color,
 } from 'three';
 import { rgb565torgb } from '../../utilities/rgb565torgb.js';
+import { decodeOctNormal } from '../../utilities/decodeOctNormal.js';
 
 const DRACO_ATTRIBUTE_MAP = {
 	RGB: 'color',
@@ -82,6 +83,8 @@ export class PNTSLoader extends PNTSLoaderBase {
 				// handle non compressed case
 				const POINTS_LENGTH = featureTable.getData( 'POINTS_LENGTH' );
 				const POSITION = featureTable.getData( 'POSITION', POINTS_LENGTH, 'FLOAT', 'VEC3' );
+				const NORMAL = featureTable.getData( 'NORMAL', POINTS_LENGTH, 'FLOAT', 'VEC3' );
+				const NORMAL_OCT16P = featureTable.getData( 'NORMAL', POINTS_LENGTH, 'UNSIGNED_BYTE', 'VEC2' );
 				const RGB = featureTable.getData( 'RGB', POINTS_LENGTH, 'UNSIGNED_BYTE', 'VEC3' );
 				const RGBA = featureTable.getData( 'RGBA', POINTS_LENGTH, 'UNSIGNED_BYTE', 'VEC4' );
 				const RGB565 = featureTable.getData( 'RGB565', POINTS_LENGTH, 'UNSIGNED_SHORT', 'SCALAR' );
@@ -116,6 +119,33 @@ export class PNTSLoader extends PNTSLoaderBase {
 
 				}
 
+				if ( NORMAL !== null ) {
+
+					geometry.setAttribute( 'normal', new BufferAttribute( NORMAL, 3, false ) );
+
+				} else if ( NORMAL_OCT16P !== null ) {
+
+					const decodedNormals = new Float32Array( POINTS_LENGTH * 3 );
+
+					const n = new Vector3();
+
+					for ( let i = 0; i < POINTS_LENGTH; i ++ ) {
+
+						const x = NORMAL_OCT16P[ i * 2 ];
+						const y = NORMAL_OCT16P[ i * 2 + 1 ];
+
+						const normal = decodeOctNormal( x, y, n );
+
+						decodedNormals[ i * 3 ] = normal.x;
+						decodedNormals[ i * 3 + 1 ] = normal.y;
+						decodedNormals[ i * 3 + 2 ] = normal.z;
+
+					}
+
+					geometry.setAttribute( 'normal', new BufferAttribute( decodedNormals, 3, false ) );
+
+				}
+
 				if ( RGBA !== null ) {
 
 					geometry.setAttribute( 'color', new BufferAttribute( RGBA, 4, true ) );
@@ -164,8 +194,6 @@ export class PNTSLoader extends PNTSLoaderBase {
 
 			[
 				'BATCH_LENGTH',
-				'NORMAL',
-				'NORMAL_OCT16P',
 			].forEach( ( feature ) => {
 
 				if ( feature in featureTable.header ) {
diff --git a/src/utilities/decodeOctNormal.js b/src/utilities/decodeOctNormal.js
new file mode 100644
index 0000000..675091a
--- /dev/null
+++ b/src/utilities/decodeOctNormal.js
@@ -0,0 +1,47 @@
+import { Vector2, MathUtils, Vector3 } from 'three';
+
+const f = new Vector2();
+
+/**
+ * Decode an octahedron-encoded normal (as a pair of 8-bit unsigned numbers) into a Vector3.
+ *
+ * Resources:
+ * - https://stackoverflow.com/a/74745666/2704779
+ * - https://knarkowicz.wordpress.com/2014/04/16/octahedron-normal-vector-encoding/
+ * @param {number} x The unsigned 8-bit X coordinate on the projected octahedron.
+ * @param {number} y The unsigned 8-bit Y coordinate on the projected octahedron.
+ * @param {Vector3} [target] The target vector.
+ */
+export function decodeOctNormal( x, y, target = new Vector3() ) {
+
+	f.set( x, y ).divideScalar( 256 ).multiplyScalar( 2 ).subScalar( 1 );
+
+	target.set( f.x, f.y, 1 - Math.abs( f.x ) - Math.abs( f.y ) );
+
+	const t = MathUtils.clamp( - target.z, 0, 1 );
+
+	if ( target.x >= 0 ) {
+
+		target.setX( target.x - t );
+
+	} else {
+
+		target.setX( target.x + t );
+
+	}
+
+	if ( target.y >= 0 ) {
+
+		target.setY( target.y - t );
+
+	} else {
+
+		target.setY( target.y + t );
+
+	}
+
+	target.normalize();
+
+	return target;
+
+}


Test patch:
diff --git a/test/decodeOctNormal.test.js b/test/decodeOctNormal.test.js
new file mode 100644
index 0000000..bdea3f4
--- /dev/null
+++ b/test/decodeOctNormal.test.js
@@ -0,0 +1,39 @@
+import { decodeOctNormal } from '../src/utilities/decodeOctNormal';
+
+const PRECISION = 0.005;
+
+describe( 'decodeOctNormal', () => {
+
+	it.each( [
+		// 4 corners of the projected octahedron
+		[ 0, 0, 0, 0, - 1 ],
+		[ 255, 255, 0, 0, - 1 ],
+		[ 0, 255, 0, 0, - 1 ],
+		[ 255, 0, 0, 0, - 1 ],
+
+		// Center of the projected octahedron
+		[ 127, 127, 0, 0, 1 ],
+
+		// Midpoint of left edge of the projected octahedron
+		[ 0, 127, - 1, 0, 0 ],
+
+		// Midpoint of right edge of the projected octahedron
+		[ 255, 127, 1, 0, 0 ],
+
+		// Midpoint of top edge of the projected octahedron
+		[ 127, 255, 0, 1, 0 ],
+
+		// Midpoint of bottom edge of the projected octahedron
+		[ 127, 0, 0, - 1, 0 ],
+	] )( 'should decode (%s, %s) into [%s, %s, %s]', ( encX, encY, x, y, z ) => {
+
+		const result = decodeOctNormal( encX, encY );
+
+		expect( result.length() ).toBeCloseTo( 1, PRECISION );
+		expect( result.x ).toBeCloseTo( x, PRECISION );
+		expect( result.y ).toBeCloseTo( y, PRECISION );
+		expect( result.z ).toBeCloseTo( z, PRECISION );
+
+	} );
+
+} );