From 477ad1c24fd6dad1405cba9f7e3a824f4f4f461e Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Fri, 6 Nov 2026 17:27:25 +0100 Subject: [PATCH 1/6] fix(PLYWriter): stop the header comments accumulation The writer added the generated comment and the TextureFile comment to the headerComments option at each write. Thus each write added one more comment to the option and to the file. The writer now makes a new comment list for each write. The headerComments option stays the same. Fixes #3719 --- Sources/IO/Geometry/PLYWriter/index.js | 7 ++-- .../Geometry/PLYWriter/test/testPLYWriter.js | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js diff --git a/Sources/IO/Geometry/PLYWriter/index.js b/Sources/IO/Geometry/PLYWriter/index.js index de974ee63ac..bdb74567d51 100644 --- a/Sources/IO/Geometry/PLYWriter/index.js +++ b/Sources/IO/Geometry/PLYWriter/index.js @@ -30,11 +30,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'); diff --git a/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js b/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js new file mode 100644 index 00000000000..ed1ec5deea6 --- /dev/null +++ b/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js @@ -0,0 +1,35 @@ +import { it, expect } from 'vitest'; +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', + ]); +}); From 0d824ba111d7c6b8beae37daa78bd4ac92826126 Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Fri, 6 Nov 2026 17:27:28 +0100 Subject: [PATCH 2/6] fix(PLYWriter): use the withUVs and withColors options The writer replaced the withUVs and withColors values with a test for the data. Thus a false value had no effect. The writer now writes the texture coordinates and the colors only when the option is true and the data has the attribute. Fixes #3720 --- Sources/IO/Geometry/PLYWriter/index.js | 24 ++++++++------ .../Geometry/PLYWriter/test/testPLYWriter.js | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/Sources/IO/Geometry/PLYWriter/index.js b/Sources/IO/Geometry/PLYWriter/index.js index bdb74567d51..a3291ced433 100644 --- a/Sources/IO/Geometry/PLYWriter/index.js +++ b/Sources/IO/Geometry/PLYWriter/index.js @@ -291,15 +291,19 @@ 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); + let scalars = null; + if (withColors) { + scalars = polyData.getPointData().getScalars(); + } + const writeColors = !!scalars; const fileType = dataByteOrder ? 0 : 1; @@ -313,8 +317,8 @@ function writePLY( numPts, numPolys, withNormals, - withUVs, - withColors, + writeUVs, + writeColors, withIndices ); diff --git a/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js b/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js index ed1ec5deea6..64bc4bd0394 100644 --- a/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js +++ b/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js @@ -1,4 +1,5 @@ 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'; @@ -33,3 +34,33 @@ it('vtkPLYWriter does not change the header comments option', () => { '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'); +}); From a8cbac07c8fae0c8d3e239088b30980233475981 Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Fri, 6 Nov 2026 17:28:04 +0100 Subject: [PATCH 3/6] fix(PLYWriter): declare normals only when the polydata has normals The withNormals option is true by default, and the writer did not compare it with the data. Thus the header declared nx, ny and nz, but the vertex records did not have normals. A reader then read all the vertices incorrectly, in the ASCII and the binary formats. The writer now declares and writes the normals only when the option is true and the polydata has normals. Fixes #3718 --- Sources/IO/Geometry/PLYWriter/index.js | 10 +++++-- .../Geometry/PLYWriter/test/testPLYWriter.js | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/Sources/IO/Geometry/PLYWriter/index.js b/Sources/IO/Geometry/PLYWriter/index.js index a3291ced433..41aff8c23b7 100644 --- a/Sources/IO/Geometry/PLYWriter/index.js +++ b/Sources/IO/Geometry/PLYWriter/index.js @@ -305,6 +305,12 @@ function writePLY( } const writeColors = !!scalars; + let normals = null; + if (withNormals) { + normals = polyData.getPointData().getNormals(); + } + const writeNormals = !!normals; + const fileType = dataByteOrder ? 0 : 1; writer.writeHeader( @@ -316,14 +322,12 @@ function writePLY( tCoordsName, numPts, numPolys, - withNormals, + writeNormals, writeUVs, writeColors, withIndices ); - const normals = polyData.getPointData().getNormals(); - // points / vertices for (let i = 0; i < numPts; i++) { // eslint-disable-next-line prefer-const diff --git a/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js b/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js index 64bc4bd0394..25076f6ba0b 100644 --- a/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js +++ b/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js @@ -64,3 +64,32 @@ it('vtkPLYWriter does not write UVs and colors when the options are false', () = }); 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); +}); From 6fcda14b751471a254b84dd8c481b9688dd59927 Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Fri, 6 Nov 2026 17:28:44 +0100 Subject: [PATCH 4/6] fix(PLYWriter): write colors only for 8 bit RGB or RGBA scalars The writer read all point scalars as 3 component 8 bit colors. Thus RGBA colors got incorrect values, and float scalars became uchar values. The writer now writes colors only for 8 bit scalars with 3 or more components. It reads each tuple with the number of components of the array. Fixes #3722 --- Sources/IO/Geometry/PLYWriter/index.js | 19 ++++++++--- .../Geometry/PLYWriter/test/testPLYWriter.js | 32 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/Sources/IO/Geometry/PLYWriter/index.js b/Sources/IO/Geometry/PLYWriter/index.js index 41aff8c23b7..333afc0c3f9 100644 --- a/Sources/IO/Geometry/PLYWriter/index.js +++ b/Sources/IO/Geometry/PLYWriter/index.js @@ -299,9 +299,20 @@ function writePLY( } const writeUVs = !!textureCoords; + // PLY colors are 8 bit RGB values. Other scalars are not colors. let scalars = null; + let scalarsComps = 0; if (withColors) { - scalars = polyData.getPointData().getScalars(); + 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; @@ -364,9 +375,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) { diff --git a/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js b/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js index 25076f6ba0b..66380c161b8 100644 --- a/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js +++ b/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js @@ -93,3 +93,35 @@ it('vtkPLYWriter declares normals only when the polydata has normals', () => { // 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'); +}); From 23f8542878fa15dae515ce763f66b443cf02beb8 Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Fri, 6 Nov 2026 17:29:18 +0100 Subject: [PATCH 5/6] fix(PLYWriter): apply the transform to the points The writer accepted the transform option, but it wrote the points without the transform. The writer now applies the transform matrix to each point. Fixes #3721 --- Sources/IO/Geometry/PLYWriter/index.js | 8 +++----- Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js | 13 +++++++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Sources/IO/Geometry/PLYWriter/index.js b/Sources/IO/Geometry/PLYWriter/index.js index 333afc0c3f9..b24b11fa65f 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 { @@ -341,12 +342,9 @@ function writePLY( // 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 diff --git a/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js b/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js index 66380c161b8..177f3f50786 100644 --- a/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js +++ b/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js @@ -125,3 +125,16 @@ it('vtkPLYWriter does not write float scalars as colors', () => { 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', + ]); +}); From cffc006132aba3bcae208f3b21289ba15361ba90 Mon Sep 17 00:00:00 2001 From: Adnane Belmadiaf Date: Fri, 6 Nov 2026 17:30:33 +0100 Subject: [PATCH 6/6] fix(PLYWriter): write each face with its own point count The writer read the cell array in steps of 4 and wrote 3 point ids for each face. The binary face size also used 3 ids. Thus quads and larger polygons lost points, and all the subsequent faces were incorrect. The writer now reads the point count of each cell, and writes all the point ids of the cell. The binary face size uses the length of the cell array. Fixes #3717 --- Sources/IO/Geometry/PLYWriter/index.js | 39 ++++++++++-------- .../Geometry/PLYWriter/test/testPLYWriter.js | 40 +++++++++++++++++++ 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/Sources/IO/Geometry/PLYWriter/index.js b/Sources/IO/Geometry/PLYWriter/index.js index b24b11fa65f..2ec5d8c7401 100644 --- a/Sources/IO/Geometry/PLYWriter/index.js +++ b/Sources/IO/Geometry/PLYWriter/index.js @@ -108,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 @@ -181,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, @@ -242,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, @@ -388,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 index 177f3f50786..a6871684c45 100644 --- a/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js +++ b/Sources/IO/Geometry/PLYWriter/test/testPLYWriter.js @@ -138,3 +138,43 @@ it('vtkPLYWriter applies the transform to the points', () => { '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]); +});