diff --git a/Documentation/content/docs/gallery/OBJWriterWithIcon.jpg b/Documentation/content/docs/gallery/OBJWriterWithIcon.jpg new file mode 100644 index 00000000000..ab255e492f0 Binary files /dev/null and b/Documentation/content/docs/gallery/OBJWriterWithIcon.jpg differ diff --git a/Documentation/content/examples/index.md b/Documentation/content/examples/index.md index 740eab8b1aa..4e81c621d59 100644 --- a/Documentation/content/examples/index.md +++ b/Documentation/content/examples/index.md @@ -181,6 +181,7 @@ This will allow you to see the some live code running in your browser. Just pick [![PolyDataReader Example][PolyDataReaderWithIcon]](./PolyDataReader.html "VTK legacy reader(VTK)") [![ElevationReader Example][ElevationReaderWithIcon]](./ElevationReader.html "Elevation reader(CSV, JPG)") [![OBJReader Example][OBJReaderWithIcon]](./OBJReader.html "OBJ reader(OBJ, MTL, JPG)") +[![OBJWriter Example][OBJWriterWithIcon]](./OBJWriter.html "OBJ writer(OBJ, ZIP)") [![PDBReader Example][PDBReaderWithIcon]](./PDBReader.html "PDB reader(OBJ, MTL, JPG)") [![XMLImageDataWriter Example][XMLImageDataWriterWithIcon]](./XMLImageDataWriter.html "ImageData XML writer(VTI)") [![XMLPolyDataDataWriter Example][XMLPolyDataWriterWithIcon]](./XMLPolyDataWriter.html "PolyData XML writer(VTP)") @@ -206,6 +207,7 @@ This will allow you to see the some live code running in your browser. Just pick [PolyDataReaderWithIcon]: ../docs/gallery/VTKReaderWithIcon.jpg [ElevationReaderWithIcon]: ../docs/gallery/ElevationReaderWithIcon.jpg [OBJReaderWithIcon]: ../docs/gallery/OBJReaderWithIcon.jpg +[OBJWriterWithIcon]: ../docs/gallery/OBJWriterWithIcon.jpg [PDBReaderWithIcon]: ../docs/gallery/PDBReaderWithIcon.jpg [XMLImageDataWriterWithIcon]: ../docs/gallery/XMLImageDataWriterWithIcon.jpg [XMLPolyDataWriterWithIcon]: ../docs/gallery/XMLPolyDataWriterWithIcon.jpg diff --git a/Sources/IO/Misc/OBJWriter/example/index.js b/Sources/IO/Misc/OBJWriter/example/index.js new file mode 100644 index 00000000000..f03ed955793 --- /dev/null +++ b/Sources/IO/Misc/OBJWriter/example/index.js @@ -0,0 +1,177 @@ +import '@kitware/vtk.js/favicon'; + +// Load the rendering pieces we want to use (for both WebGL and WebGPU) +import '@kitware/vtk.js/Rendering/Profiles/Geometry'; + +import '@kitware/vtk.js/IO/Core/DataAccessHelper/HttpDataAccessHelper'; // HTTP + zip + +import vtkActor from '@kitware/vtk.js/Rendering/Core/Actor'; +import vtkCubeSource from '@kitware/vtk.js/Filters/Sources/CubeSource'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import vtkFullScreenRenderWindow from '@kitware/vtk.js/Rendering/Misc/FullScreenRenderWindow'; +import vtkMapper from '@kitware/vtk.js/Rendering/Core/Mapper'; +import vtkOBJWriter from '@kitware/vtk.js/IO/Misc/OBJWriter'; +import vtkTexture from '@kitware/vtk.js/Rendering/Core/Texture'; + +// ---------------------------------------------------------------------------- +// Standard rendering code setup +// ---------------------------------------------------------------------------- + +const fullScreenRenderer = vtkFullScreenRenderWindow.newInstance(); +const renderer = fullScreenRenderer.getRenderer(); +const renderWindow = fullScreenRenderer.getRenderWindow(); + +const resetCamera = renderer.resetCamera; +const render = renderWindow.render; + +// Create a colored texture for each face of the cube +const canvas = document.createElement('canvas'); +canvas.width = 256; +canvas.height = 256; +const ctx = canvas.getContext('2d'); + +// Define colors for each face (order: +X, -X, +Y, -Y, +Z, -Z) +const faceColors = [ + '#FF0000', // +X: Red + '#00FF00', // -X: Green + '#0000FF', // +Y: Blue + '#FFFF00', // -Y: Yellow + '#00FFFF', // +Z: Cyan + '#FF00FF', // -Z: Magenta +]; + +// Draw each face as a square in a 3x2 grid +const faceSize = 128; +for (let i = 0; i < 6; i++) { + const x = (i % 3) * faceSize; + const y = Math.floor(i / 3) * faceSize; + ctx.fillStyle = faceColors[i]; + ctx.fillRect(x, y, faceSize, faceSize); +} + +function callback(source, tex) { + const writer = vtkOBJWriter.newInstance(); + writer.setInputData(source.getOutputData()); + writer.setTexture(tex); + + // const objContent = writer.getOutputData(); + // const mtlContent = writer.getMtl(); + + const zip = writer.exportAsZip(); + zip.then((zipData) => { + const blob = new Blob([zipData], { type: 'application/zip' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.text = 'Download'; + link.style.position = 'absolute'; + link.style.left = '50%'; + link.style.bottom = '10px'; + link.style.background = 'white'; + link.style.padding = '5px'; + global.writer = writer; + document.body.appendChild(link); + }); +} + +// Create a cube source +const cubeSource = vtkCubeSource.newInstance({ + xLength: 1, + yLength: 1, + zLength: 1, +}); + +// Create vtkTexture from canvas +const texture = vtkTexture.newInstance(); +const image = new Image(); +image.src = canvas.toDataURL(); +image.onload = () => { + texture.setImage(image); + callback(cubeSource, texture); + render(); +}; + +// Add tcoords to the cube for proper texture mapping +const addCubeTCoords = (polyData) => { + const points = polyData.getPoints().getData(); + const numPoints = points.length / 3; + const tcoords = new Float32Array(numPoints * 2); + + // Map each face to a region of the texture + const polys = polyData.getPolys().getData(); + const faces = []; + const f = []; + let i = 0; + while (i < polys.length) { + f.length = 0; // Reset face array + const n = polys[i++]; + for (let j = 0; j < n; j++) { + f.push(polys[i++]); + } + faces.push(f); + } + + // Map each face's points to the corresponding region in the texture + faces.forEach((face, faceIdx) => { + // Compute texture region for this face + const col = faceIdx % 3; + const row = Math.floor(faceIdx / 3); + const u0 = col / 3; + const v0 = row / 2; + const u1 = (col + 1) / 3; + const v1 = (row + 1) / 2; + + // Assign tcoords to each point in the face + face.forEach((ptIdx, j) => { + // For quads, assign corners in order + // For triangles, just map to the region + let u; + let v; + if (face.length === 4) { + // Map corners: 0-bottom left, 1-bottom right, 2-top right, 3-top left + if (j === 0) { + u = u0; + v = v0; + } else if (j === 1) { + u = u1; + v = v0; + } else if (j === 2) { + u = u1; + v = v1; + } else { + u = u0; + v = v1; + } + } else { + // For triangles, just use barycentric mapping + u = u0 + (u1 - u0) * (j % 2); + v = v0 + (v1 - v0) * Math.floor(j / 2); + } + tcoords[ptIdx * 2] = u; + tcoords[ptIdx * 2 + 1] = v; + }); + }); + + const tcoordArray = vtkDataArray.newInstance({ + name: 'TextureCoordinates', + numberOfComponents: 2, + values: tcoords, + }); + polyData.getPointData().setTCoords(tcoordArray); +}; + +const polyData = cubeSource.getOutputData(); +addCubeTCoords(polyData); + +const mapper = vtkMapper.newInstance(); +mapper.setInputData(polyData); + +const actor = vtkActor.newInstance(); +actor.setMapper(mapper); +actor.addTexture(texture); + +renderer.addActor(actor); +resetCamera(); +render(); + +global.fullScreenRenderer = fullScreenRenderer; diff --git a/Sources/IO/Misc/OBJWriter/index.d.ts b/Sources/IO/Misc/OBJWriter/index.d.ts new file mode 100644 index 00000000000..77851a76d72 --- /dev/null +++ b/Sources/IO/Misc/OBJWriter/index.d.ts @@ -0,0 +1,103 @@ +import vtkPolyData from '../../../Common/DataModel/PolyData'; +import vtkTexture from '../../../Rendering/Core/Texture'; +import { vtkAlgorithm, vtkObject } from '../../../interfaces'; + +/** + * + */ +export interface IOBJWriterInitialValues { + modelFilename?: string; + materialFilename?: string; + texture?: vtkTexture; + textureFileName?: string; +} + +type vtkOBJWriterBase = vtkObject & vtkAlgorithm; + +export interface vtkOBJWriter extends vtkOBJWriterBase { + /** + * Get the zip file containing the OBJ and MTL files. + */ + exportAsZip(): Promise; + + /** + * Get the MTL file as a string. + */ + getMtl(): string; + + /** + * + * @param inData + * @param outData + */ + requestData(inData: any, outData: any): void; + + /** + * Set the material filename. + * @param materialFilename + * @returns {boolean} true if the material file name was set successfully + */ + setMaterialFilename(materialFilename: string): boolean; + + /** + * Set the model filename. + * @param modelFilename + */ + setModelFilename(modelFilename: string): boolean; + + /** + * Set the texture instance. + * @param {vtkTexture} texture + * @returns {boolean} true if the texture was set successfully + */ + setTexture(texture: vtkTexture): boolean; + + /** + * Set the texture file name. + * @param {string} textureFileName + * @returns {boolean} true if the texture file name was set successfully + */ + setTextureFileName(textureFileName: string): boolean; +} + +/** + * Method used to decorate a given object (publicAPI+model) with vtkOBJWriter characteristics. + * + * @param publicAPI object on which methods will be bounds (public) + * @param model object on which data structure will be bounds (protected) + * @param {IOBJWriterInitialValues} [initialValues] (default: {}) + */ +export function extend( + publicAPI: object, + model: object, + initialValues?: IOBJWriterInitialValues +): void; + +/** + * Method used to create a new instance of vtkOBJWriter + * @param {IOBJWriterInitialValues} [initialValues] for pre-setting some of its content + */ +export function newInstance( + initialValues?: IOBJWriterInitialValues +): vtkOBJWriter; + +/** + * + * @param {vktPolyData} polyData + */ +export function writeOBJ(polyData: vtkPolyData): vtkPolyData; + +/** + * vtkOBJWriter writes wavefront obj (.obj) files in ASCII form. OBJ files + * contain the geometry including lines, triangles and polygons. Normals and + * texture coordinates on points are also written if they exist. + * + * One can specify a texture passing a vtkTexture using `setTexture`. If a texture is + * set, additional .mtl and .png files are generated. + */ +export declare const vtkOBJWriter: { + newInstance: typeof newInstance; + extend: typeof extend; + writeOBJ: typeof writeOBJ; +}; +export default vtkOBJWriter; diff --git a/Sources/IO/Misc/OBJWriter/index.js b/Sources/IO/Misc/OBJWriter/index.js new file mode 100644 index 00000000000..f2acdf1d970 --- /dev/null +++ b/Sources/IO/Misc/OBJWriter/index.js @@ -0,0 +1,254 @@ +import { zipSync, strToU8 } from 'fflate'; +import macro from 'vtk.js/Sources/macros'; +import vtkCellArray from 'vtk.js/Sources/Common/Core/CellArray/index'; +import vtkTriangleStrip from 'vtk.js/Sources/Common/DataModel/TriangleStrip/index'; + +const { vtkErrorMacro } = macro; + +// ---------------------------------------------------------------------------- +// Global methods +// ---------------------------------------------------------------------------- + +// ---------------------------------------------------------------------------- +// vtkOBJWriter methods +// ---------------------------------------------------------------------------- + +const writeFaces = (faces, withNormals, withTCoords) => { + let outputData = ''; + const fd = faces.getData(); + + let offset = 0; + while (offset < fd.length) { + const faceSize = fd[offset++]; + outputData += 'f'; + for (let i = 0; i < faceSize; i++) { + outputData += ` ${fd[offset + i] + 1}`; + if (withTCoords) { + outputData += `/${fd[offset + i] + 1}`; + if (withNormals) { + outputData += `//${fd[offset + i] + 1}`; + } + } else if (withNormals) { + outputData += `//${fd[offset + i] + 1}`; + } + } + offset += faceSize; + outputData += '\n'; + } + return outputData; +}; + +const writeLines = (lines) => { + let outputData = ''; + const ld = lines.getData(); + + let offset = 0; + while (offset < ld.length) { + const lineSize = ld[offset++]; + outputData += 'l'; + for (let i = 0; i < lineSize; i++) { + outputData += ` ${ld[offset + i] + 1}`; + } + offset += lineSize; + outputData += '\n'; + } + + return outputData; +}; + +const writePoints = (pts, normals, tcoords) => { + const outputData = []; + const nbPts = pts.getNumberOfPoints(); + + let p; + + // Positions + for (let i = 0; i < nbPts; i++) { + p = pts.getPoint(i); + outputData.push(`v ${p[0]} ${p[1]} ${p[2]}`); + } + + // Normals + if (normals) { + for (let i = 0; i < nbPts; i++) { + p = normals.getTuple(i); + outputData.push(`vn ${p[0]} ${p[1]} ${p[2]}`); + } + } + + // Textures + if (tcoords) { + for (let i = 0; i < nbPts; i++) { + p = tcoords.getTuple(i); + + if (p[0] !== -1.0) { + outputData.push(`vt ${p[0]} ${p[1]}`); + } + } + } + + return `${outputData.join('\n')}\n`; +}; + +const writeMTL = (materialName, textureFileName) => { + const outputData = []; + outputData.push(`newmtl ${materialName}`); + outputData.push(`map_Kd ${textureFileName}`); + return outputData.join('\n'); +}; + +const writeOBJ = (polyData, materialFilename, materialName) => { + let outputData = '# VTK.js generated OBJ File\n'; + const pts = polyData.getPoints(); + const polys = polyData.getPolys(); + const strips = polyData.getStrips() ? polyData.getStrips().getData() : null; + const lines = polyData.getLines(); + + const normals = polyData.getPointData().getNormals(); + const tcoords = polyData.getPointData().getTCoords(); + + const hasPtNormals = normals !== null; + const hasPtTCoords = tcoords !== null; + + if (!pts) { + vtkErrorMacro('No data to write!'); + return outputData; + } + + if (materialFilename) { + // Write material library + outputData += `mtllib ${materialFilename}\n`; + } + + // Write material if a texture is specified + if (materialName) { + // declare material in obj file + outputData += `usemtl ${materialName}\n`; + } + + // Write points + outputData += writePoints(pts, normals, tcoords); + + // Decompose any triangle strips into triangles + const polyStrips = vtkCellArray.newInstance(); + if (strips && strips.length > 0) { + vtkTriangleStrip.decomposeStrip(pts, polyStrips); + } + + // Write triangle strips + if (polyStrips.getNumberOfCells() > 0) { + outputData += writeFaces(polyStrips, hasPtNormals, hasPtTCoords); + } + + // Write polygons. + if (polys) { + outputData += writeFaces(polys, hasPtNormals, hasPtTCoords); + } + + // Write lines. + if (lines) { + outputData += writeLines(lines); + } + + return outputData; +}; + +// ---------------------------------------------------------------------------- +// Static API +// ---------------------------------------------------------------------------- + +export const STATIC = { + writeOBJ, +}; + +// ---------------------------------------------------------------------------- +// vtkOBJWriter methods +// ---------------------------------------------------------------------------- + +function vtkOBJWriter(publicAPI, model) { + // Set our className + model.classHierarchy.push('vtkOBJWriter'); + + publicAPI.exportAsZip = () => { + publicAPI.update(); + const modelFilename = model.modelFilename; + const materialFilename = model.materialFilename; + const textureFileName = model.textureFileName; + const imageData = model.texture.getInputAsJsImageData?.(); + + const zipContent = {}; + + zipContent[`${modelFilename}.obj`] = strToU8(model.output[0]); + zipContent[`${materialFilename}.mtl`] = strToU8(model.mtl); + + const canvas = new OffscreenCanvas(imageData.width, imageData.height); + const ctx = canvas.getContext('2d'); + ctx.putImageData(imageData, 0, 0); + + return new Promise((resolve) => { + canvas.toBlob(async (blob) => { + const arrayBuffer = await blob.arrayBuffer(); + zipContent[`${textureFileName}.png`] = new Uint8Array(arrayBuffer); + resolve(zipSync(zipContent)); + }, 'image/png'); + }); + }; + + publicAPI.requestData = (inData, outData) => { + const input = inData[0]; + + if (!input || !input.isA('vtkPolyData')) { + vtkErrorMacro('Invalid or missing vtkPolyData input'); + return; + } + + // Update output + const materialFilename = `${model.materialFilename}.mtl`; + const textureFileName = `${model.textureFileName}.png`; + outData[0] = writeOBJ(input, materialFilename, model.materialName); + model.mtl = writeMTL(model.materialName, textureFileName); + }; +} + +// ---------------------------------------------------------------------------- +// Object factory +// ---------------------------------------------------------------------------- + +const DEFAULT_VALUES = { + modelFilename: 'model', + materialName: 'mat_01', + materialFilename: 'material', + texture: null, + textureFileName: 'texture', +}; + +// ---------------------------------------------------------------------------- + +export function extend(publicAPI, model, initialValues = {}) { + Object.assign(model, DEFAULT_VALUES, initialValues); + + // Make this a VTK object + macro.obj(publicAPI, model); + + // Also make it an algorithm with one input and one output + macro.algo(publicAPI, model, 1, 1); + + macro.get(publicAPI, model, ['mtl']); + macro.set(publicAPI, model, [ + 'modelFilename', + 'materialFilename', + 'texture', + 'textureFileName', + ]); + + // Object specific methods + vtkOBJWriter(publicAPI, model); +} + +// ---------------------------------------------------------------------------- + +export const newInstance = macro.newInstance(extend, 'vtkOBJWriter'); + +// ---------------------------------------------------------------------------- + +export default { newInstance, extend, ...STATIC }; diff --git a/Sources/IO/Misc/OBJWriter/test/testOBJWriter.js b/Sources/IO/Misc/OBJWriter/test/testOBJWriter.js new file mode 100644 index 00000000000..dea23b2c3de --- /dev/null +++ b/Sources/IO/Misc/OBJWriter/test/testOBJWriter.js @@ -0,0 +1,98 @@ +import test from 'tape'; +import vtkPLYReader from 'vtk.js/Sources/IO/Geometry/PLYReader'; +import vtkOBJWriter from 'vtk.js/Sources/IO/Misc/OBJWriter'; + +const plyFile = `ply +format ascii 1.0 +comment Created by vtk.js with normals and UVs +element vertex 8 +property float x +property float y +property float z +property uchar red +property uchar green +property uchar blue +property float nx +property float ny +property float nz +property float u +property float v +element face 6 +property list uchar uint vertex_indices +end_header +2.000000 0.000000 -2.000000 255 255 0 0.577350 -0.577350 -0.577350 1.0 0.0 +2.000000 0.000000 0.000000 255 0 0 0.577350 -0.577350 0.577350 1.0 0.0 +0.000000 0.000000 0.000000 0 0 0 -0.577350 -0.577350 0.577350 0.0 0.0 +0.000000 0.000000 -2.000000 0 255 0 -0.577350 -0.577350 -0.577350 0.0 0.0 +2.000000 2.000000 -2.000000 255 255 255 0.577350 0.577350 -0.577350 1.0 1.0 +0.000000 2.000000 -2.000000 0 255 255 -0.577350 0.577350 -0.577350 0.0 1.0 +0.000000 2.000000 0.000000 0 0 255 -0.577350 0.577350 0.577350 0.0 1.0 +2.000000 2.000000 0.000000 255 0 255 0.577350 0.577350 0.577350 1.0 1.0 +4 0 1 2 3 +4 4 5 6 7 +4 0 4 7 1 +4 1 7 6 2 +4 2 6 5 3 +4 4 0 3 5`; + +const expectedMtlContent = `newmtl mat_01 +map_Kd texture.png`; + +const expectedObjContent = `# VTK.js generated OBJ File +mtllib material.mtl +usemtl mat_01 +v 2 0 -2 +v 2 0 0 +v 0 0 0 +v 0 0 -2 +v 2 2 -2 +v 0 2 -2 +v 0 2 0 +v 2 2 0 +vn 0.5773500204086304 -0.5773500204086304 -0.5773500204086304 +vn 0.5773500204086304 -0.5773500204086304 0.5773500204086304 +vn -0.5773500204086304 -0.5773500204086304 0.5773500204086304 +vn -0.5773500204086304 -0.5773500204086304 -0.5773500204086304 +vn 0.5773500204086304 0.5773500204086304 -0.5773500204086304 +vn -0.5773500204086304 0 -0.5773500204086304 +vn 0 0 0.5773500204086304 +vn 0 0 0.5773500204086304 +vt 1 0 +vt 1 0 +vt 0 0 +vt 0 0 +vt 1 1 +vt 0 1 +vt 0 1 +vt 1 1 +f 1/1//1 2/2//2 3/3//3 4/4//4 +f 5/5//5 6/6//6 7/7//7 8/8//8 +f 1/1//1 5/5//5 8/8//8 2/2//2 +f 2/2//2 8/8//8 7/7//7 3/3//3 +f 3/3//3 7/7//7 6/6//6 4/4//4 +f 5/5//5 1/1//1 4/4//4 6/6//6 +`; + +test('OBJWriter: Check conversion from PLY to OBJ', (t) => { + const reader = vtkPLYReader.newInstance(); + reader.parseAsText(plyFile); + const output = reader.getOutputData(); + const writer = vtkOBJWriter.newInstance(); + writer.setInputData(output); + + const objContent = writer.getOutputData(); + + t.equal( + objContent, + expectedObjContent, + 'OBJ content should match expected output' + ); + + t.equal( + writer.getMtl(), + expectedMtlContent, + 'MTL content should match expected texture declaration' + ); + + t.end(); +}); diff --git a/Sources/IO/Misc/index.js b/Sources/IO/Misc/index.js index 57704ad77c3..de69712f159 100644 --- a/Sources/IO/Misc/index.js +++ b/Sources/IO/Misc/index.js @@ -5,6 +5,7 @@ import vtkJSONNucleoReader from './JSONNucleoReader'; import vtkJSONReader from './JSONReader'; import vtkMTLReader from './MTLReader'; import vtkOBJReader from './OBJReader'; +import vtkOBJWriter from './OBJWriter'; import vtkPDBReader from './PDBReader'; import vtkSkyboxReader from './SkyboxReader'; import vtkGCodeReader from './GCodeReader'; @@ -17,6 +18,7 @@ export default { vtkJSONReader, vtkMTLReader, vtkOBJReader, + vtkOBJWriter, vtkPDBReader, vtkSkyboxReader, vtkGCodeReader,