diff --git a/applications/src/main/java/boofcv/app/MeshViewerApp.java b/applications/src/main/java/boofcv/app/MeshViewerApp.java index d738664289..4168d8643f 100644 --- a/applications/src/main/java/boofcv/app/MeshViewerApp.java +++ b/applications/src/main/java/boofcv/app/MeshViewerApp.java @@ -26,14 +26,11 @@ import boofcv.struct.image.ImageType; import boofcv.struct.image.InterleavedU8; import boofcv.struct.mesh.VertexMesh; -import org.apache.commons.io.FilenameUtils; import javax.swing.*; import java.awt.*; import java.io.File; -import java.io.FileInputStream; import java.io.IOException; -import java.util.Locale; /** * Very simple app for opening and viewing a 3D mesh @@ -48,18 +45,8 @@ public MeshViewerApp() { private static void loadFile( File file ) { // Load the mesh var mesh = new VertexMesh(); - String extension = FilenameUtils.getExtension(file.getName()).toLowerCase(Locale.ENGLISH); - var type = switch (extension) { - case "ply" -> PointCloudIO.Format.PLY; - case "stl" -> PointCloudIO.Format.STL; - case "obj" -> PointCloudIO.Format.OBJ; - default -> { - throw new RuntimeException("Unknown file type"); - } - }; - - try (var input = new FileInputStream(file)) { - PointCloudIO.load(type, input, mesh); + try { + PointCloudIO.load(file, mesh); } catch (IOException e) { e.printStackTrace(System.err); System.exit(1); diff --git a/main/boofcv-geo/src/main/java/boofcv/alg/cloud/PointCloudReader.java b/main/boofcv-geo/src/main/java/boofcv/alg/cloud/PointCloudReader.java index 79ed635a3e..8bddc57bd2 100644 --- a/main/boofcv-geo/src/main/java/boofcv/alg/cloud/PointCloudReader.java +++ b/main/boofcv-geo/src/main/java/boofcv/alg/cloud/PointCloudReader.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021, Peter Abeles. All Rights Reserved. + * Copyright (c) 2024, Peter Abeles. All Rights Reserved. * * This file is part of BoofCV (http://boofcv.org). * @@ -38,6 +38,9 @@ public interface PointCloudReader { */ int size(); + /** True if each point has a color */ + boolean colors(); + /** * Copies the point */ @@ -58,6 +61,8 @@ static PointCloudReader wrap3FRGB( float[] cloud, float[] rgb, int offset, int l @Override public int size() {return length;} + @Override public boolean colors() {return true;} + @Override public void get( int index, Point3D_F32 point ) { int i = offset + index*3; @@ -85,6 +90,8 @@ static PointCloudReader wrapF32( List cloud ) { return new PointCloudReader() { @Override public int size() {return cloud.size();} + @Override public boolean colors() {return false;} + @Override public void get( int index, Point3D_F32 point ) {point.setTo(cloud.get(index));} @Override public void get( int index, Point3D_F64 point ) {convert(cloud.get(index), point);} @@ -97,6 +104,8 @@ static PointCloudReader wrapF64( List cloud ) { return new PointCloudReader() { @Override public int size() {return cloud.size();} + @Override public boolean colors() {return false;} + @Override public void get( int index, Point3D_F32 point ) {convert(cloud.get(index), point);} @Override public void get( int index, Point3D_F64 point ) {point.setTo(cloud.get(index));} @@ -109,6 +118,8 @@ static PointCloudReader wrapF32RGB( List cloud ) { return new PointCloudReader() { @Override public int size() {return cloud.size();} + @Override public boolean colors() {return true;} + @Override public void get( int index, Point3D_F32 point ) {point.setTo(cloud.get(index));} @Override public void get( int index, Point3D_F64 point ) {convert(cloud.get(index), point);} @@ -121,6 +132,8 @@ static PointCloudReader wrapF64RGB( List cloud ) { return new PointCloudReader() { @Override public int size() {return cloud.size();} + @Override public boolean colors() {return true;} + @Override public void get( int index, Point3D_F32 point ) {convert(cloud.get(index), point);} @Override public void get( int index, Point3D_F64 point ) {point.setTo(cloud.get(index));} @@ -133,6 +146,8 @@ static PointCloudReader wrapF32( List cloud, int[] rgb ) { return new PointCloudReader() { @Override public int size() {return cloud.size();} + @Override public boolean colors() {return true;} + @Override public void get( int index, Point3D_F32 point ) {point.setTo(cloud.get(index));} @Override public void get( int index, Point3D_F64 point ) {convert(cloud.get(index), point);} @@ -145,6 +160,8 @@ static PointCloudReader wrapF64( List cloud, int[] rgb ) { return new PointCloudReader() { @Override public int size() {return cloud.size();} + @Override public boolean colors() {return true;} + @Override public void get( int index, Point3D_F32 point ) {convert(cloud.get(index), point);} @Override public void get( int index, Point3D_F64 point ) {point.setTo(cloud.get(index));} @@ -159,6 +176,8 @@ static PointCloudReader wrap( Generic op, int size ) { @Override public int size() {return size;} + @Override public boolean colors() {return true;} + @Override public void get( int index, Point3D_F32 point ) { op.get(index, p); point.x = (float)p.x; diff --git a/main/boofcv-geo/src/main/java/boofcv/alg/cloud/PointCloudWriter.java b/main/boofcv-geo/src/main/java/boofcv/alg/cloud/PointCloudWriter.java index 6745f1c9cc..a26aad531b 100644 --- a/main/boofcv-geo/src/main/java/boofcv/alg/cloud/PointCloudWriter.java +++ b/main/boofcv-geo/src/main/java/boofcv/alg/cloud/PointCloudWriter.java @@ -128,6 +128,25 @@ static PointCloudWriter wrapF64( DogArray cloud ) { }; } + static PointCloudWriter wrapF64( DogArray cloud, DogArray_I32 colors ) { + return new PointCloudWriter() { + @Override public void initialize( int size, boolean hasColor ) { + cloud.reserve(size); + cloud.reset(); + colors.reserve(size); + colors.reset(); + } + + @Override public void startPoint() {} + + @Override public void stopPoint() {} + + @Override public void color( int rgb ) {colors.add(rgb);} + + @Override public void location( double x, double y, double z ) {cloud.grow().setTo(x, y, z);} + }; + } + static PointCloudWriter wrapF32RGB( DogArray cloud ) { return new PointCloudWriter() { @Override public void initialize( int size, boolean hasColor ) { diff --git a/main/boofcv-io/src/main/java/boofcv/io/points/PointCloudIO.java b/main/boofcv-io/src/main/java/boofcv/io/points/PointCloudIO.java index 37d5a5a82e..7846a386eb 100644 --- a/main/boofcv-io/src/main/java/boofcv/io/points/PointCloudIO.java +++ b/main/boofcv-io/src/main/java/boofcv/io/points/PointCloudIO.java @@ -36,6 +36,7 @@ import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; import java.util.Locale; +import java.util.Map; /** * Code for reading different point cloud formats @@ -95,6 +96,8 @@ public static void save3D( Format format, @Override public int size() {return size;} + @Override public boolean colors() {return true;} + @Override public void get( int index, Point3D_F64 point ) {accessPoint.getPoint(index, point);} @Override public int getRGB( int index ) {return accessColor.getRGB(index);} @@ -148,6 +151,8 @@ public static void load( Format format, InputStream input, PointCloudWriter outp /** * Loads the mesh from a file. File type is determined by the file's extension. * + *

For OBJ files, if there are multiple shapes defined then only the first one is returned.

+ * * @param file Which file it should load * @param mesh (Output) storage for the mesh */ @@ -160,11 +165,30 @@ public static void load( File file, VertexMesh mesh ) throws IOException { default -> throw new RuntimeException("Unknown file type: " + extension); }; + + // OBJ files are special. They need to read in multiple files to get the texture map image and + // there can be multiple shapes defined. This will handle all those situations + if (type == PointCloudIO.Format.OBJ) { + var reader = new ObjLoadFromFiles(); + reader.load(file, mesh); + return; + } + try (var input = new FileInputStream(file)) { PointCloudIO.load(type, input, mesh); } } + /** + * Loads a set of {@link VertexMesh} from an OBJ file. This can ready any type of OBJ file as it doesn't make + * assumptions about what is contained inside of it + */ + public static Map loadObj( File file ) { + var reader = new ObjLoadFromFiles(); + reader.load(file, null); + return reader.getShapeToMesh(); + } + /** * Reads a 3D mesh from the input stream in the specified format and writes it to the output. * diff --git a/main/boofcv-io/src/main/java/boofcv/io/points/impl/ObjFileCodec.java b/main/boofcv-io/src/main/java/boofcv/io/points/impl/ObjFileCodec.java index bee6f4bc93..3d55651a42 100644 --- a/main/boofcv-io/src/main/java/boofcv/io/points/impl/ObjFileCodec.java +++ b/main/boofcv-io/src/main/java/boofcv/io/points/impl/ObjFileCodec.java @@ -24,6 +24,7 @@ import georegression.struct.point.Point2D_F32; import georegression.struct.point.Point3D_F32; import georegression.struct.point.Point3D_F64; +import org.apache.commons.io.FilenameUtils; import org.ddogleg.struct.DogArray_I32; import java.io.*; @@ -44,30 +45,71 @@ public static void save( PointCloudReader cloud, Writer writer ) throws IOExcept var obj = new ObjFileWriter(writer); obj.addComment("Created by BoofCV"); + boolean hasColor = cloud.colors(); + var point = new Point3D_F64(); int N = cloud.size(); for (int i = 0; i < N; i++) { cloud.get(i, point); - obj.addVertex(point.x, point.y, point.z); + if (hasColor) { + addRgbVertex(obj, point, cloud.getRGB(i)); + } else { + obj.addVertex(point.x, point.y, point.z); + } obj.addPoint(-1); } } + /** + * Creates a MTL file. This is required if the mesh is textured mapped + * + * @param textureFile Path to texture mapped file + * @param writer Where the MTL will be written to + */ + public static void saveMtl( String textureFile, Writer writer ) throws IOException { + // Use hard coded values + String text = """ + newmtl %s + Ka 1.0 1.0 1.0 + Kd 1.0 1.0 1.0 + Ks 0.0 0.0 0.0 + d 1.0 + Ns 0.0 + illum 0 + map_Kd %s"""; + + String baseName = FilenameUtils.getBaseName(textureFile); + writer.write(String.format(text, baseName, textureFile)); + } + public static void save( VertexMesh mesh, Writer writer ) throws IOException { var obj = new ObjFileWriter(writer); obj.addComment("Created by BoofCV"); + // If there's a texture file, add a material + if (!mesh.textureName.isEmpty()) { + String baseName = FilenameUtils.getBaseName(mesh.textureName); + obj.addLibrary(baseName + ".mtl"); + obj.addMaterial(baseName); + } + + boolean hasVertexColors = mesh.rgb.size > 0; + // First save the vertexes int N = mesh.vertexes.size(); for (int i = 0; i < N; i++) { Point3D_F64 p = mesh.vertexes.getTemp(i); - obj.addVertex(p.x, p.y, p.z); + if (hasVertexColors) { + addRgbVertex(obj, p, mesh.rgb.get(i)); + } else { + obj.addVertex(p.x, p.y, p.z); + } } // Save vertex normals for (int i = 0; i < mesh.normals.size(); i++) { Point3D_F32 p = mesh.normals.getTemp(i); - obj.addVertex(p.x, p.y, p.z); + obj.addVertexNormal(p.x, p.y, p.z); } // Save vertex textures @@ -97,8 +139,20 @@ public static void save( VertexMesh mesh, Writer writer ) throws IOException { } } + private static void addRgbVertex( ObjFileWriter obj, Point3D_F64 p, int rgb ) throws IOException { + // Convert to float format + double red = ((rgb >> 16) & 0xFF)/255.0; + double green = ((rgb >> 8) & 0xFF)/255.0; + double blue = (rgb & 0xFF)/255.0; + obj.addVertex(p.x, p.y, p.z, red, green, blue); + } + public static void load( InputStream input, PointCloudWriter output ) throws IOException { var obj = new ObjFileReader() { + @Override protected void addLibrary( String name ) {} + + @Override protected void addMaterial( String name ) {} + @Override protected void addVertex( double x, double y, double z ) { output.startPoint(); output.location(x, y, z); @@ -107,10 +161,9 @@ public static void load( InputStream input, PointCloudWriter output ) throws IOE @Override protected void addVertexWithColor( double x, double y, double z, double red, double green, double blue ) { - int rgb = ((int)(255*red)) << 16 | ((int)(255*green)) << 8 | ((int)(255*blue)); output.startPoint(); output.location(x, y, z); - output.color(rgb); + output.color(convertToInt(red, green, blue)); output.stopPoint(); } @@ -130,15 +183,20 @@ protected void addVertexWithColor( double x, double y, double z, double red, dou public static void load( InputStream input, VertexMesh output ) throws IOException { output.reset(); var obj = new ObjFileReader() { + // Library and material doesn't translate well a stream input and mesh output + // A single obj file can define multiple objects with different texture maps + @Override protected void addLibrary( String name ) {} + + @Override protected void addMaterial( String name ) {} + @Override protected void addVertex( double x, double y, double z ) { output.vertexes.append(x, y, z); } @Override protected void addVertexWithColor( double x, double y, double z, double red, double green, double blue ) { - int rgb = ((int)(255*red)) << 16 | ((int)(255*green)) << 8 | ((int)(255*blue)); output.vertexes.append(x, y, z); - output.rgb.add(rgb); + output.rgb.add(convertToInt(red, green, blue)); } @Override protected void addVertexNormal( double x, double y, double z ) { @@ -154,18 +212,36 @@ protected void addVertexWithColor( double x, double y, double z, double red, dou @Override protected void addLine( DogArray_I32 vertexes ) {} @Override protected void addFace( DogArray_I32 indexes, int vertexCount ) { - int types = indexes.size/vertexCount; - for (int idxVert = 0; idxVert < vertexCount; idxVert++) { - output.faceVertexes.add(indexes.get(idxVert)/types); - for (int i = 1; i < types; i++) { - if (indexes.get(i - 1) != indexes.get(i)) - throw new RuntimeException("Only vertexes types with the same index supported"); - } - } - - output.faceOffsets.add(output.faceVertexes.size); + addFactToMesh(indexes, vertexCount, output); } }; obj.parse(new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))); } + + static void addFactToMesh( DogArray_I32 indexes, int vertexCount, VertexMesh output ) { + // order of that indexes are specifies is always vertex/texture/normal + + boolean hasTexture = output.texture.size() > 0; + boolean hasNormals = output.normals.size() > 0; + + int typeCount = indexes.size/vertexCount; + for (int idxVert = 0; idxVert < vertexCount; idxVert++) { + int index = idxVert*typeCount;; + output.faceVertexes.add(indexes.get(index++)); + + if (hasTexture) { + output.faceVertexTextures.add(indexes.get(index++)); + } + + if (hasNormals) { + output.faceVertexNormals.add(indexes.get(index)); + } + } + + output.faceOffsets.add(output.faceVertexes.size); + } + + static int convertToInt( double red, double green, double blue ) { + return ((int)(255*red + 0.5)) << 16 | ((int)(255*green + 0.5)) << 8 | ((int)(255*blue + 0.5)); + } } diff --git a/main/boofcv-io/src/main/java/boofcv/io/points/impl/ObjFileReader.java b/main/boofcv-io/src/main/java/boofcv/io/points/impl/ObjFileReader.java index 9265823f68..5eeb4ac09e 100644 --- a/main/boofcv-io/src/main/java/boofcv/io/points/impl/ObjFileReader.java +++ b/main/boofcv-io/src/main/java/boofcv/io/points/impl/ObjFileReader.java @@ -116,8 +116,14 @@ public void parse( BufferedReader reader ) throws IOException { readFaceIndexes(words); addFace(vertexIndexes, words.length - 1); } + + case "mtllib" -> addLibrary(words[1]); + case "usemtl" -> addMaterial(words[1]); default -> handleError(actualLineCount + " Unknown object type. '" + words[0] + "'"); } + } catch (ObjLoadFromFiles.MultipleMaterials e) { + // this exception is thrown as a way to stop reading the file + return; } catch (Exception e) { // Skip over locally bad data handleError(actualLineCount + " Bad object description " + words[0] + " '" + e.getMessage() + "'"); @@ -161,6 +167,16 @@ private void readFaceIndexes( String[] words ) { } } + /** + * Adds an MTL library that it should load + */ + protected abstract void addLibrary( String name ); + + /** + * Tells it that these vectors belongs the material defined in the MTL library + */ + protected abstract void addMaterial( String name ); + protected abstract void addVertex( double x, double y, double z ); /** Adds a color for a vertex. RGB is specified in a range of 0 to 1.0 */ diff --git a/main/boofcv-io/src/main/java/boofcv/io/points/impl/ObjLoadFromFiles.java b/main/boofcv-io/src/main/java/boofcv/io/points/impl/ObjLoadFromFiles.java new file mode 100644 index 0000000000..d36f642e4c --- /dev/null +++ b/main/boofcv-io/src/main/java/boofcv/io/points/impl/ObjLoadFromFiles.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2024, Peter Abeles. All Rights Reserved. + * + * This file is part of BoofCV (http://boofcv.org). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package boofcv.io.points.impl; + +import boofcv.struct.mesh.VertexMesh; +import lombok.Getter; +import org.ddogleg.struct.DogArray_I32; +import org.jetbrains.annotations.Nullable; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * Loads a OBJ file and associated MTL file. This makes assumptions that it's reading from a file system and doesn't. + * This allows it to search for files referenced in the OBJ. + */ +public class ObjLoadFromFiles { + private Map materialToTextureFile = new HashMap<>(); + + /** Returns a map containing all the shapes that were loaded from the OBJ file */ + private @Getter Map shapeToMesh = new HashMap<>(); + + /** Indicates that it found multiple materials in the file. */ + private @Getter boolean ignoredMaterial = false; + + // If true then the material being seen is the first one + private boolean first = true; + + /** + * Loads the OBJ file from disk. Opens up and reads in any related MTL files as needed. To see if any materials + * were ignored because a mesh was passed in, look at {@link #ignoredMaterial}. If no material is defined then + * the output mesh will be placed in the map with a key of an empty String. + * + * @param outputMesh If not null the proved mesh will be used for storage. If more than one mesh is defined in + * the file then only the first one will be read. + */ + public void load( File file, @Nullable VertexMesh outputMesh ) { + ignoredMaterial = false; + first = true; + var reader = new ObjFileReader() { + VertexMesh active; + + { + // If the output mesh was passed in default to that, otherwise create a new mesh to default to + active = outputMesh != null ? outputMesh : new VertexMesh(); + active.reset(); + shapeToMesh.put("", active); + } + + @Override protected void addLibrary( String name ) { + readTextureFilesFromMTL(new File(file.getParent(), name)); + } + + @Override protected void addMaterial( String name ) { + // If this is the first material to add AND a shape has not been defined already, + // remove the default mesh from the map since it isn't being used + if (first && active.vertexes.size() == 0) { + shapeToMesh.remove(""); + } + + if (outputMesh != null) { + // If first isn't true that means another material was encountered. If vertexes are not zero that + // means it was reading in a default object already + if (!first || active.vertexes.size() != 0) { + ignoredMaterial = true; + throw new MultipleMaterials(); + } + } else { + active = new VertexMesh(); + } + + first = false; + + // Set the texture file name if it's specified for this material + if (materialToTextureFile.containsKey(name)) { + active.textureName = materialToTextureFile.get(name); + } else { + System.err.println("Unknown material '" + name + "'"); + } + shapeToMesh.put(name, active); + } + + @Override protected void addVertex( double x, double y, double z ) { + active.vertexes.append(x, y, z); + } + + @Override + protected void addVertexWithColor( double x, double y, double z, double red, double green, double blue ) { + active.vertexes.append(x, y, z); + active.rgb.add(ObjFileCodec.convertToInt(red, green, blue)); + } + + @Override protected void addVertexNormal( double x, double y, double z ) { + active.normals.append((float)x, (float)y, (float)z); + } + + @Override protected void addVertexTexture( double x, double y ) { + active.texture.append((float)x, (float)y); + } + + @Override protected void addPoint( int vertex ) {} + + @Override protected void addLine( DogArray_I32 vertexes ) {} + + @Override protected void addFace( DogArray_I32 indexes, int vertexCount ) { + ObjFileCodec.addFactToMesh(indexes, vertexCount, active); + } + }; + + // Read in the OBJ file + try (var input = new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)) { + reader.parse(new BufferedReader(input)); + } catch (IOException e) { + throw new RuntimeException(e); + } catch (MultipleMaterials e) { + ignoredMaterial = true; + } + } + + /** + * Just read in the texture file names from the MTL file + */ + void readTextureFilesFromMTL( File file ) { + String materialName = ""; + try (var input = new BufferedReader(new FileReader(file, StandardCharsets.UTF_8))) { + String line = input.readLine(); + while (line != null) { + try { + // Find the first word + int idx1 = line.indexOf(' '); + if (idx1 == -1) + continue; + + // Find the second word + int idx2 = line.indexOf(' ', idx1 + 1); + + // See if it's at the end of the line + if (idx2 == -1) + idx2 = line.length(); + + String command = line.substring(0, idx1); + String value = line.substring(idx1 + 1, idx2); + + switch (command) { + case "newmtl" -> materialName = value; + case "map_Kd" -> materialToTextureFile.put(materialName, value); + } + } finally { + line = input.readLine(); + } + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** Thrown as a way to escape if it starts to read a second material and a mesh was passed in */ + static class MultipleMaterials extends RuntimeException {} +} diff --git a/main/boofcv-io/src/test/java/boofcv/io/points/impl/TestObjFileCodec.java b/main/boofcv-io/src/test/java/boofcv/io/points/impl/TestObjFileCodec.java index 423df0f8b6..f9aba7cdf2 100644 --- a/main/boofcv-io/src/test/java/boofcv/io/points/impl/TestObjFileCodec.java +++ b/main/boofcv-io/src/test/java/boofcv/io/points/impl/TestObjFileCodec.java @@ -24,6 +24,7 @@ import boofcv.testing.BoofStandardJUnit; import georegression.struct.point.Point3D_F64; import org.ddogleg.struct.DogArray; +import org.ddogleg.struct.DogArray_I32; import org.ejml.UtilEjml; import org.junit.jupiter.api.Test; @@ -31,7 +32,6 @@ import java.io.IOException; import java.io.StringWriter; import java.util.ArrayList; -import java.util.List; import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -39,28 +39,57 @@ public class TestObjFileCodec extends BoofStandardJUnit { @Test void encode_decode_cloud() throws IOException { - List expected = new ArrayList<>(); + encode_decode_cloud(false); + encode_decode_cloud(true); + } + + void encode_decode_cloud( boolean colorTest ) throws IOException { + var expectedP = new ArrayList(); + var expectedColors = new DogArray_I32(); for (int i = 0; i < 10; i++) { - expected.add(new Point3D_F64(i*123.45, i - 1.01, i + 2.34)); + expectedP.add(new Point3D_F64(i*123.45, i - 1.01, i + 2.34)); + expectedColors.add(i*125); } - var found = new DogArray<>(Point3D_F64::new); - var output = new StringWriter(); - ObjFileCodec.save(PointCloudReader.wrapF64(expected), output); + if (colorTest) { + ObjFileCodec.save(PointCloudReader.wrapF64(expectedP, expectedColors.data), output); + } else { + ObjFileCodec.save(PointCloudReader.wrapF64(expectedP), output); + } var input = new ByteArrayInputStream(output.toString().getBytes(UTF_8)); - ObjFileCodec.load(input, PointCloudWriter.wrapF64(found)); - assertEquals(expected.size(), found.size); - for (int i = 0; i < found.size; i++) { - assertEquals(0.0, found.get(i).distance(expected.get(i)), UtilEjml.TEST_F64); + var foundP = new DogArray<>(Point3D_F64::new); + var foundColors = new DogArray_I32(); + + if (colorTest) { + ObjFileCodec.load(input, PointCloudWriter.wrapF64(foundP, foundColors)); + } else { + ObjFileCodec.load(input, PointCloudWriter.wrapF64(foundP)); + } + + if (colorTest) { + assertTrue(expectedColors.isEquals(foundColors)); + } + + assertEquals(expectedP.size(), foundP.size); + for (int i = 0; i < foundP.size; i++) { + assertEquals(0.0, foundP.get(i).distance(expectedP.get(i)), UtilEjml.TEST_F64); } } @Test void encode_decode_mesh() throws IOException { + encode_decode_mesh(false); + encode_decode_mesh(true); + } + + void encode_decode_mesh( boolean colorTest ) throws IOException { var mesh = new VertexMesh(); for (int i = 0; i < 10; i++) { mesh.vertexes.append(i, 2, 3); + if (colorTest) + mesh.rgb.add(i*18); + for (int foo = 0; foo < 3; foo++) { int vertIndex = (i*3 + foo)%10; mesh.faceVertexes.add(vertIndex); @@ -79,6 +108,8 @@ public class TestObjFileCodec extends BoofStandardJUnit { assertTrue(mesh.faceVertexes.isEquals(foundMesh.faceVertexes)); assertTrue(mesh.faceOffsets.isEquals(foundMesh.faceOffsets)); + assertTrue(mesh.rgb.isEquals(foundMesh.rgb)); + for (int i = 0; i < mesh.vertexes.size(); i++) { Point3D_F64 expected = mesh.vertexes.getTemp(i); Point3D_F64 found = foundMesh.vertexes.getTemp(i); diff --git a/main/boofcv-io/src/test/java/boofcv/io/points/impl/TestObjFileReader.java b/main/boofcv-io/src/test/java/boofcv/io/points/impl/TestObjFileReader.java index f74c795b95..615c6b54f3 100644 --- a/main/boofcv-io/src/test/java/boofcv/io/points/impl/TestObjFileReader.java +++ b/main/boofcv-io/src/test/java/boofcv/io/points/impl/TestObjFileReader.java @@ -57,10 +57,6 @@ protected void addVertexWithColor( double x, double y, double z, double red, dou throw new RuntimeException("Egads"); } - @Override protected void addVertexNormal( double x, double y, double z ) {} - - @Override protected void addVertexTexture( double x, double y ) {} - @Override protected void addFace( DogArray_I32 vertexes, int vertexCount ) { assertEquals(3, vertexes.size); assertTrue(vertexes.isEquals(0, 1, 2)); @@ -154,10 +150,6 @@ protected void addVertexWithColor( double x, double y, double z, double red, dou colors.grow().setTo(red, green, blue); } - @Override protected void addVertexNormal( double x, double y, double z ) {} - - @Override protected void addVertexTexture( double x, double y ) {} - @Override protected void addFace( DogArray_I32 vertexes, int vertexCount ) {} }; reader.parse(new BufferedReader(new StringReader(text))); @@ -192,15 +184,6 @@ protected void addVertexWithColor( double x, double y, double z, double red, dou var reader = new DummyReader() { int count = 0; - @Override protected void addVertex( double x, double y, double z ) {} - - @Override - protected void addVertexWithColor( double x, double y, double z, double red, double green, double blue ) {} - - @Override protected void addVertexNormal( double x, double y, double z ) {} - - @Override protected void addVertexTexture( double x, double y ) {} - @Override protected void addFace( DogArray_I32 vertexes, int vertexCount ) { assertEquals(3, vertexes.size); if (count == 0) { @@ -237,10 +220,6 @@ protected void addVertexWithColor( double x, double y, double z, double red, dou vertexCount++; } - @Override protected void addVertexNormal( double x, double y, double z ) {} - - @Override protected void addVertexTexture( double x, double y ) {} - @Override protected void addFace( DogArray_I32 vertexes, int vertexCount ) { assertEquals(6, vertexes.size); assertTrue(vertexes.isEquals(2, 1, 0, 0, 1, 2)); @@ -252,16 +231,7 @@ protected void addVertexWithColor( double x, double y, double z, double red, dou } @Test void ensureIndex() { - var reader = new DummyReader() { - @Override protected void addVertex( double x, double y, double z ) {} - - @Override - protected void addVertexWithColor( double x, double y, double z, double red, double green, double blue ) {} - - @Override protected void addVertexNormal( double x, double y, double z ) {} - - @Override protected void addVertexTexture( double x, double y ) {} - }; + var reader = new DummyReader(){}; // Vertexes are stored in 1-index but need to make sure it's converted to 0-index assertEquals(0, reader.ensureIndex(1)); @@ -275,6 +245,19 @@ protected void addVertexWithColor( double x, double y, double z, double red, dou } static abstract class DummyReader extends ObjFileReader { + @Override protected void addLibrary( String name ) {} + + @Override protected void addMaterial( String name ) {} + + @Override protected void addVertex( double x, double y, double z ) {} + + @Override + protected void addVertexWithColor( double x, double y, double z, double red, double green, double blue ) {} + + @Override protected void addVertexNormal( double x, double y, double z ) {} + + @Override protected void addVertexTexture( double x, double y ) {} + @Override protected void addPoint( int vertex ) { fail("there are no points"); } diff --git a/main/boofcv-io/src/test/java/boofcv/io/points/impl/TestObjLoadFileSystem.java b/main/boofcv-io/src/test/java/boofcv/io/points/impl/TestObjLoadFileSystem.java new file mode 100644 index 0000000000..e0199177f3 --- /dev/null +++ b/main/boofcv-io/src/test/java/boofcv/io/points/impl/TestObjLoadFileSystem.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2024, Peter Abeles. All Rights Reserved. + * + * This file is part of BoofCV (http://boofcv.org). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package boofcv.io.points.impl; + +import boofcv.io.UtilIO; +import boofcv.struct.mesh.VertexMesh; +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +public class TestObjLoadFileSystem { + private final static String fileObj = "file.obj"; + private final static String fileMtl = "file.mtl"; + + File workDir; + + @BeforeEach public void setup() { + try { + workDir = Files.createTempDirectory("obj").toFile(); + } catch (IOException ignore) { + } + } + + @AfterEach public void teardown() { + UtilIO.deleteRecursive(workDir); + } + + /** + * No texture mapping and everything is contained in a single file + */ + @Test void noMaterials() { + String text = """ + v 0.0 0.0 0.0 + v 0.0 1.0 0.0 + f 1 2 3 + """; + save(fileObj, text); + + var alg = new ObjLoadFromFiles(); + alg.load(new File(workDir, fileObj), null); + Map found = alg.getShapeToMesh(); + assertEquals(1, found.size()); + + VertexMesh mesh = found.get(""); + + assertEquals(2, mesh.vertexes.size()); + assertEquals(1, mesh.size()); + } + + @Test void multipleMaterials() { + createMultipleMaterialFiles(); + + var alg = new ObjLoadFromFiles(); + alg.load(new File(workDir, fileObj), null); + Map found = alg.getShapeToMesh(); + assertEquals(2, found.size()); + + VertexMesh mesh1 = found.get("a"); + assertEquals("a.jpg", mesh1.textureName); + assertEquals(2, mesh1.vertexes.size()); + assertEquals(0, mesh1.faceNormals.size()); + + + VertexMesh mesh2 = found.get("b"); + assertEquals("b.jpg", mesh2.textureName); + assertEquals(2, mesh2.vertexes.size()); + assertEquals(1, mesh2.size()); + assertEquals(0, mesh2.vertexes.getTemp(0).distance(1.0, 0, 0)); + assertEquals(0, mesh2.vertexes.getTemp(1).distance(0.0, 2, 0)); + } + + @Test void singleMesh_noMaterial() { + String text = """ + v 0.0 0.0 0.0 + v 0.0 1.0 0.0 + f 1 2 3 + """; + save(fileObj, text); + + var mesh = new VertexMesh(); + mesh.rgb.add(1); // test to see if it calls reset + + var alg = new ObjLoadFromFiles(); + alg.load(new File(workDir, fileObj), mesh); + Map found = alg.getShapeToMesh(); + + assertFalse(alg.isIgnoredMaterial()); + assertEquals(1, found.size()); + assertSame(mesh, found.get("")); + + assertEquals(2, mesh.vertexes.size()); + assertEquals(1, mesh.size()); + assertEquals(0, mesh.rgb.size()); + } + + @Test void singleMesh_multipleMaterial() { + createMultipleMaterialFiles(); + + var mesh = new VertexMesh(); + mesh.rgb.add(1); // test to see if it calls reset + + var alg = new ObjLoadFromFiles(); + alg.load(new File(workDir, fileObj), mesh); + Map found = alg.getShapeToMesh(); + assertTrue(alg.isIgnoredMaterial()); + + assertEquals(1, found.size()); + assertSame(mesh, found.get("a")); + + assertEquals(2, mesh.vertexes.size()); + assertEquals(0, mesh.vertexes.getTemp(0).distance(0.0, 0, 0)); + assertEquals(0, mesh.vertexes.getTemp(1).distance(0.0, 1, 0)); + assertEquals(0, mesh.size()); + assertEquals(0, mesh.rgb.size()); + } + + private void save( String fileName, String text ) { + try { + FileUtils.writeStringToFile(new File(workDir, fileName), text, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private void createMultipleMaterialFiles() { + String textObj = """ + mtllib file.mtl + usemtl a + v 0.0 0.0 0.0 + v 0.0 1.0 0.0 + usemtl b + v 1.0 0.0 0.0 + v 0.0 2.0 0.0 + f 1 2 3 + """; + save(fileObj, textObj); + + String textMtl = """ + newmtl a + map_Kd a.jpg + newmtl b + map_Kd b.jpg + """; + save(fileMtl, textMtl); + } +} diff --git a/main/boofcv-types/src/main/java/boofcv/struct/mesh/VertexMesh.java b/main/boofcv-types/src/main/java/boofcv/struct/mesh/VertexMesh.java index 91dc489d51..a2b80982b5 100644 --- a/main/boofcv-types/src/main/java/boofcv/struct/mesh/VertexMesh.java +++ b/main/boofcv-types/src/main/java/boofcv/struct/mesh/VertexMesh.java @@ -50,18 +50,24 @@ public class VertexMesh { /** Optional vertex colors in RGB format */ public final DogArray_I32 rgb = new DogArray_I32(); - /** Which indexes correspond to normal for a face */ + /** Which vertex indexes correspond to normal for a face */ public final DogArray_I32 faceVertexes = new DogArray_I32(); - /** Which indexes correspond to each vertex in a face */ + /** + * Which texture indexes correspond to normal for a face. If this array is empty then we assume that is one + * texture coordinate for each face vector that appears in the same order. + */ + public final DogArray_I32 faceVertexTextures = new DogArray_I32(); + + /** Which indexes correspond to each vertex normal in a face */ + public final DogArray_I32 faceVertexNormals = new DogArray_I32(); + + /** Specifies which normal is the normal for the face's plane */ public final DogArray_I32 faceNormals = new DogArray_I32(); /** Start index of each face + the last index */ public final DogArray_I32 faceOffsets = new DogArray_I32(); - /** Which indexes correspond to each vertex normal in a face */ - public final DogArray_I32 vectorNormal = new DogArray_I32(); - /** Name of the texture file associated with this mesh */ public String textureName = ""; @@ -102,8 +108,16 @@ public void getTexture( int which, DogArray output ) { int idx1 = faceOffsets.get(which + 1); output.reset().resize(idx1 - idx0); - for (int i = idx0; i < idx1; i++) { - texture.getCopy(i, output.get(i - idx0)); + + // See if custom indexes are specified for a face's texture coordinates + if (faceVertexTextures.isEmpty()) { + for (int i = idx0; i < idx1; i++) { + texture.getCopy(i, output.get(i - idx0)); + } + } else { + for (int i = idx0; i < idx1; i++) { + texture.getCopy(faceVertexTextures.get(i), output.get(i - idx0)); + } } } @@ -141,8 +155,14 @@ public void getFaceVectors( int which, DogArray output ) { int idx1 = faceOffsets.get(which + 1); output.reset().resize(idx1 - idx0); - for (int i = idx0; i < idx1; i++) { - vertexes.getCopy(faceVertexes.get(i), output.get(i - idx0)); + if (faceVertexes.isEmpty()) { + for (int i = idx0; i < idx1; i++) { + vertexes.getCopy(i, output.get(i - idx0)); + } + } else { + for (int i = idx0; i < idx1; i++) { + vertexes.getCopy(faceVertexes.get(i), output.get(i - idx0)); + } } } @@ -204,9 +224,10 @@ public VertexMesh setTo( VertexMesh src ) { this.normals.setTo(src.normals); this.rgb.setTo(src.rgb); this.faceVertexes.setTo(src.faceVertexes); + this.faceVertexTextures.setTo(src.faceVertexTextures); this.faceNormals.setTo(src.faceNormals); this.faceOffsets.setTo(src.faceOffsets); - this.vectorNormal.setTo(src.vectorNormal); + this.faceVertexNormals.setTo(src.faceVertexNormals); this.textureName = src.textureName; return this; } @@ -217,9 +238,10 @@ public void reset() { normals.reset(); rgb.reset(); faceVertexes.reset(); + faceVertexTextures.reset(); faceNormals.reset(); faceOffsets.reset().add(0); - vectorNormal.reset(); + faceVertexNormals.reset(); textureName = ""; }