diff --git a/Sources/IO/Geometry/PLYWriter/index.js b/Sources/IO/Geometry/PLYWriter/index.js index de974ee63ac..2ec5d8c7401 100644 --- a/Sources/IO/Geometry/PLYWriter/index.js +++ b/Sources/IO/Geometry/PLYWriter/index.js @@ -1,3 +1,4 @@ +import { vec3 } from 'gl-matrix'; import macro from 'vtk.js/Sources/macros'; import { @@ -30,11 +31,12 @@ const writeHeader = ( format = fileType ? 'binary_little_endian' : 'binary_big_endian'; } else format = 'ascii'; - headerComments.unshift('VTK.js generated PLY File'); + // Make a new list for each write, so the headerComments option stays the same. + const comments = ['VTK.js generated PLY File', ...(headerComments || [])]; if (textureFileName) { - headerComments.push(`TextureFile ${textureFileName}`); + comments.push(`TextureFile ${textureFileName}`); } - const commentElements = headerComments + const commentElements = comments .map((comment) => `comment ${comment}`) .join('\n'); @@ -106,11 +108,15 @@ const binaryWriter = () => { ) => { const vertexCount = polyData.getPoints().getNumberOfPoints(); ft = fileType; - // 1 byte shape descriptor - // 3 vertex indices at ${indexByteCount} bytes - const faceListLength = withIndices - ? numPolys * (indexByteCount * 3 + 1) - : 0; + // Each face has 1 byte for the point count and indexByteCount bytes + // for each point index. The cell array has the count and the indices + // of each face. + let faceListLength = 0; + if (withIndices) { + const cellArrayLength = polyData.getPolys().getData().length; + faceListLength = + numPolys + (cellArrayLength - numPolys) * indexByteCount; + } // 3 position values at 4 bytes // 3 normal values at 4 bytes @@ -179,15 +185,13 @@ const binaryWriter = () => { vOffset += 1; } }, - writeFace: (n, x, y, z) => { + writeFace: (cellArray, offset, n) => { output.setUint8(fOffset, n); fOffset += 1; - output.setUint32(fOffset, x, ft); - fOffset += indexByteCount; - output.setUint32(fOffset, y, ft); - fOffset += indexByteCount; - output.setUint32(fOffset, z, ft); - fOffset += indexByteCount; + for (let j = 1; j <= n; j++) { + output.setUint32(fOffset, cellArray[offset + j], ft); + fOffset += indexByteCount; + } }, writeFooter: (polyData) => {}, getOutputData: () => output, @@ -240,8 +244,9 @@ const asciiWriter = () => { } fileContent += '\n'; }, - writeFace: (n, x, y, z) => { - fileContent += `${n} ${x} ${y} ${z}\n`; + writeFace: (cellArray, offset, n) => { + const ids = cellArray.subarray(offset + 1, offset + 1 + n); + fileContent += `${n} ${ids.join(' ')}\n`; }, writeFooter: (polyData) => {}, getOutputData: () => fileContent, @@ -290,15 +295,36 @@ function writePLY( const numPts = inPts.getNumberOfPoints(); const numPolys = polys.getNumberOfCells(); - // textureCoords / uvs - const textureCoords = polyData.getPointData().getTCoords(); - // eslint-disable-next-line no-param-reassign - withUVs = !(textureCoords === null); + // The writer writes an attribute only when the option is true and the data + // has the attribute. + let textureCoords = null; + if (withUVs) { + textureCoords = polyData.getPointData().getTCoords(); + } + const writeUVs = !!textureCoords; - // scalars / colors - const scalars = polyData.getPointData().getScalars(); - // eslint-disable-next-line no-param-reassign - withColors = !(scalars === null); + // PLY colors are 8 bit RGB values. Other scalars are not colors. + let scalars = null; + let scalarsComps = 0; + if (withColors) { + const pointScalars = polyData.getPointData().getScalars(); + if (pointScalars) { + const dataType = pointScalars.getDataType(); + scalarsComps = pointScalars.getNumberOfComponents(); + const is8Bit = + dataType === 'Uint8Array' || dataType === 'Uint8ClampedArray'; + if (is8Bit && scalarsComps >= 3) { + scalars = pointScalars; + } + } + } + const writeColors = !!scalars; + + let normals = null; + if (withNormals) { + normals = polyData.getPointData().getNormals(); + } + const writeNormals = !!normals; const fileType = dataByteOrder ? 0 : 1; @@ -311,22 +337,17 @@ function writePLY( tCoordsName, numPts, numPolys, - withNormals, - withUVs, - withColors, + writeNormals, + writeUVs, + writeColors, withIndices ); - const normals = polyData.getPointData().getNormals(); - // points / vertices for (let i = 0; i < numPts; i++) { - // eslint-disable-next-line prefer-const - let p = inPts.getPoint(i); - - // TODO: apply transform matrix + const p = inPts.getPoint(i); if (transform) { - // vec3.transformMat4(p, p, transform); + vec3.transformMat4(p, p, transform); } // coords @@ -355,9 +376,9 @@ function writePLY( } if (scalars) { - r = scalars.getData()[i * 3]; - g = scalars.getData()[i * 3 + 1]; - b = scalars.getData()[i * 3 + 2]; + r = scalars.getData()[i * scalarsComps]; + g = scalars.getData()[i * scalarsComps + 1]; + b = scalars.getData()[i * scalarsComps + 2]; } if (normals) { @@ -370,9 +391,13 @@ function writePLY( } // polys / indices + // Each cell in the cell array is the point count, then the point ids. const pd = polys.getData(); - for (let i = 0, l = pd.length; i < l; i += 4) { - writer.writeFace(pd[i + 0], pd[i + 1], pd[i + 2], pd[i + 3]); + let offset = 0; + while (offset < pd.length) { + const n = pd[offset]; + writer.writeFace(pd, offset, n); + offset += n + 1; } writer.writeFooter(polyData); diff --git a/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js b/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js new file mode 100644 index 00000000000..a6871684c45 --- /dev/null +++ b/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js @@ -0,0 +1,180 @@ +import { it, expect } from 'vitest'; +import vtkDataArray from 'vtk.js/Sources/Common/Core/DataArray'; +import vtkPolyData from 'vtk.js/Sources/Common/DataModel/PolyData'; +import vtkPLYWriter from 'vtk.js/Sources/IO/Geometry/PLYWriter'; + +function makeTriangle() { + const polyData = vtkPolyData.newInstance(); + polyData + .getPoints() + .setData(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), 3); + polyData.getPolys().setData(new Uint32Array([3, 0, 1, 2])); + return polyData; +} + +function getHeader(text) { + return text.split('end_header')[0].split('\n'); +} + +function getBody(text) { + return text.split('end_header\n')[1].trim().split('\n'); +} + +it('vtkPLYWriter does not change the header comments option', () => { + const polyData = makeTriangle(); + const writer = vtkPLYWriter.newInstance({ headerComments: ['my comment'] }); + const output = []; + writer.requestData([polyData], output); + writer.requestData([polyData], output); + + expect(writer.getHeaderComments()).toEqual(['my comment']); + const comments = getHeader(output[0]).filter((l) => l.startsWith('comment')); + expect(comments).toEqual([ + 'comment VTK.js generated PLY File', + 'comment my comment', + ]); +}); + +it('vtkPLYWriter does not write UVs and colors when the options are false', () => { + const polyData = makeTriangle(); + polyData.getPointData().setTCoords( + vtkDataArray.newInstance({ + numberOfComponents: 2, + values: new Float32Array([0, 0, 1, 0, 0, 1]), + }) + ); + polyData.getPointData().setScalars( + vtkDataArray.newInstance({ + numberOfComponents: 3, + values: new Uint8Array([255, 0, 0, 0, 255, 0, 0, 0, 255]), + }) + ); + + const writer = vtkPLYWriter.newInstance({ + withNormals: false, + withUVs: false, + withColors: false, + }); + writer.setInputData(polyData); + const text = writer.getOutputData(); + const header = getHeader(text); + ['u', 'v', 'red', 'green', 'blue'].forEach((name) => { + expect(header).not.toContain(`property float ${name}`); + expect(header).not.toContain(`property uchar ${name}`); + }); + expect(getBody(text)[0]).toBe('0 0 0'); +}); + +it('vtkPLYWriter declares normals only when the polydata has normals', () => { + const polyData = makeTriangle(); + const writer = vtkPLYWriter.newInstance(); + writer.setInputData(polyData); + let text = writer.getOutputData(); + expect(getHeader(text)).not.toContain('property float nx'); + expect(getBody(text)[0]).toBe('0 0 0'); + + polyData.getPointData().setNormals( + vtkDataArray.newInstance({ + numberOfComponents: 3, + values: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]), + }) + ); + const writerWithNormals = vtkPLYWriter.newInstance(); + writerWithNormals.setInputData(polyData); + text = writerWithNormals.getOutputData(); + expect(getHeader(text)).toContain('property float nx'); + expect(getBody(text)[0]).toBe('0 0 0 0 0 1'); + + const binaryWriter = vtkPLYWriter.newInstance({ format: 'binary' }); + binaryWriter.setInputData(makeTriangle()); + const view = binaryWriter.getOutputData(); + const header = new TextDecoder().decode(view.buffer).split('end_header\n')[0]; + const headerLength = header.length + 'end_header\n'.length; + // 3 points with 3 floats, and 1 face with 1 count byte and 3 indices. + expect(view.byteLength - headerLength).toBe(3 * 3 * 4 + 1 + 3 * 4); +}); + +it('vtkPLYWriter reads colors with the number of components of the scalars', () => { + const polyData = makeTriangle(); + polyData.getPointData().setScalars( + vtkDataArray.newInstance({ + numberOfComponents: 4, + values: new Uint8Array([255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255]), + }) + ); + const writer = vtkPLYWriter.newInstance({ withNormals: false }); + writer.setInputData(polyData); + expect(getBody(writer.getOutputData()).slice(0, 3)).toEqual([ + '0 0 0 255 0 0', + '1 0 0 0 255 0', + '0 1 0 0 0 255', + ]); +}); + +it('vtkPLYWriter does not write float scalars as colors', () => { + const polyData = makeTriangle(); + polyData.getPointData().setScalars( + vtkDataArray.newInstance({ + numberOfComponents: 3, + values: new Float32Array([0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5]), + }) + ); + const writer = vtkPLYWriter.newInstance({ withNormals: false }); + writer.setInputData(polyData); + const text = writer.getOutputData(); + expect(getHeader(text)).not.toContain('property uchar red'); + expect(getBody(text)[0]).toBe('0 0 0'); +}); + +it('vtkPLYWriter applies the transform to the points', () => { + // Translation of 10 along x (column major matrix). + const transform = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 0, 0, 1]; + const writer = vtkPLYWriter.newInstance({ transform }); + writer.setInputData(makeTriangle()); + expect(getBody(writer.getOutputData())).toEqual([ + '10 0 0', + '11 0 0', + '10 1 0', + '3 0 1 2', + ]); +}); + +function makeQuadAndTriangle() { + const polyData = vtkPolyData.newInstance(); + polyData + .getPoints() + .setData( + new Float32Array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 2, 0, 0]), + 3 + ); + // One quad, then one triangle. + polyData.getPolys().setData(new Uint32Array([4, 0, 1, 2, 3, 3, 1, 4, 2])); + return polyData; +} + +it('vtkPLYWriter writes each face with its own point count', () => { + const writer = vtkPLYWriter.newInstance(); + writer.setInputData(makeQuadAndTriangle()); + const body = getBody(writer.getOutputData()); + expect(body.slice(5)).toEqual(['4 0 1 2 3', '3 1 4 2']); +}); + +it('vtkPLYWriter writes binary faces with their own point count', () => { + const writer = vtkPLYWriter.newInstance({ format: 'binary' }); + writer.setInputData(makeQuadAndTriangle()); + const view = writer.getOutputData(); + // The faces are at the end of the file: 1 + 4 * 4 bytes, then 1 + 3 * 4 bytes. + const faceStart = view.byteLength - (1 + 4 * 4) - (1 + 3 * 4); + const littleEndian = true; + expect(view.getUint8(faceStart)).toBe(4); + const quad = [0, 1, 2, 3].map((j) => + view.getUint32(faceStart + 1 + j * 4, littleEndian) + ); + expect(quad).toEqual([0, 1, 2, 3]); + const triStart = faceStart + 1 + 4 * 4; + expect(view.getUint8(triStart)).toBe(3); + const tri = [0, 1, 2].map((j) => + view.getUint32(triStart + 1 + j * 4, littleEndian) + ); + expect(tri).toEqual([1, 4, 2]); +});