diff --git a/core/.classpath b/core/.classpath index 7b26f669d..47c188a43 100644 --- a/core/.classpath +++ b/core/.classpath @@ -38,5 +38,6 @@ + diff --git a/core/OpenRocket Core.iml b/core/OpenRocket Core.iml index 3b9cedd08..453e9a5d8 100644 --- a/core/OpenRocket Core.iml +++ b/core/OpenRocket Core.iml @@ -307,6 +307,15 @@ + + + + + + + + + diff --git a/core/lib/jts-core-1.19.0.jar b/core/lib/jts-core-1.19.0.jar new file mode 100644 index 000000000..f465913d5 Binary files /dev/null and b/core/lib/jts-core-1.19.0.jar differ diff --git a/core/resources/l10n/messages.properties b/core/resources/l10n/messages.properties index 9225aff7d..b51057a5d 100644 --- a/core/resources/l10n/messages.properties +++ b/core/resources/l10n/messages.properties @@ -1556,6 +1556,8 @@ OBJOptionChooser.checkbox.removeOffset.ttip = If true, remove the offset o OBJOptionChooser.btn.showAdvanced = Show Advanced options OBJOptionChooser.checkbox.triangulate = Triangulate mesh OBJOptionChooser.checkbox.triangulate.ttip = If true, triangulate the mesh before exporting (convert all quads or high-order polygons to a triangle). +OBJOptionChooser.lbl.triangulationMethod = Triangulation method: +OBJOptionChooser.lbl.triangulationMethod.ttip = Select the desired algorithm to use for the triangulation. OBJOptionChooser.checkbox.sRGB = Export colors in sRGB OBJOptionChooser.checkbox.sRGB.ttip = If true, export colors in sRGB instead of a linear color scheme.
Is useful for instance when exporting for use in Blender. OBJOptionChooser.lbl.Scaling = Scaling: @@ -1574,6 +1576,12 @@ LevelOfDetail.LOW_QUALITY = Low quality LevelOfDetail.NORMAL_QUALITY = Normal quality LevelOfDetail.HIGH_QUALITY = High quality +! TriangulationMethod +TriangulationMethod.SIMPLE = Simple (fast) +TriangulationMethod.SIMPLE.ttip = Simple triangulation method (fast, but may produce poor results) +TriangulationMethod.DELAUNAY = Delaunay (recommended) +TriangulationMethod.DELAUNAY.ttip = Constrained Delaunay triangulation method (recommended, but may be slow for large models) + ! ThrustCurveMotorSelectionPanel TCMotorSelPan.lbl.Selrocketmotor = Select rocket motor: TCMotorSelPan.checkbox.hideSimilar = Hide very similar thrust curves diff --git a/core/src/net/sf/openrocket/file/wavefrontobj/DefaultObj.java b/core/src/net/sf/openrocket/file/wavefrontobj/DefaultObj.java index 284aab922..97f0cdc54 100644 --- a/core/src/net/sf/openrocket/file/wavefrontobj/DefaultObj.java +++ b/core/src/net/sf/openrocket/file/wavefrontobj/DefaultObj.java @@ -218,6 +218,10 @@ public final class DefaultObj implements Obj { return faces.size(); } + public List getFaces() { + return faces; + } + @Override public ObjFace getFace(int index) { return faces.get(index); @@ -458,6 +462,14 @@ public final class DefaultObj implements Obj { addFace(v, null, null); } + public void removeFace(int index) { + faces.remove(index); + } + + public void removeFace(ObjFace face) { + faces.remove(face); + } + @Override public void addFaceWithTexCoords(int... v) { addFace(v, v, null); @@ -603,5 +615,22 @@ public final class DefaultObj implements Obj { } } } + + /** + * Creates a clone of this object. + * + * @param cloneFacesAndGroups Whether the faces should be cloned + * @return a new DefaultObj object with the same properties as this object + */ + public DefaultObj clone(boolean cloneFacesAndGroups) { + DefaultObj newObj = new DefaultObj(); + newObj.setMtlFileNames(getMtlFileNames()); + ObjUtils.copyAllVertices(this, newObj); + if (cloneFacesAndGroups) { + ObjUtils.copyAllFacesAndGroups(this, newObj); + } + + return newObj; + } } diff --git a/core/src/net/sf/openrocket/file/wavefrontobj/DefaultObjEdge.java b/core/src/net/sf/openrocket/file/wavefrontobj/DefaultObjEdge.java new file mode 100644 index 000000000..134c6a5e1 --- /dev/null +++ b/core/src/net/sf/openrocket/file/wavefrontobj/DefaultObjEdge.java @@ -0,0 +1,65 @@ +package net.sf.openrocket.file.wavefrontobj; + +public class DefaultObjEdge { + /** + * The vertex index of the start of this edge + */ + private final int startVertexIndex; + /** + * The vertex index of the end of this edge + */ + private final int endVertexIndex; + + /** + * The normal index of the start of this edge + */ + private final int startNormalIndex; + /** + * The normal index of the end of this edge + */ + private final int endNormalIndex; + + /** + * The texture coordinate index of the start of this edge + */ + private final int startTexCoordIndex; + /** + * The texture coordinate index of the end of this edge + */ + private final int endTexCoordIndex; + + + public DefaultObjEdge(int startVertexIndex, int endVertexIndex, int startTexCoordIndex, int endTexCoordIndex, int startNormalIndex, int endNormalIndex) { + this.startVertexIndex = startVertexIndex; + this.endVertexIndex = endVertexIndex; + this.startTexCoordIndex = startTexCoordIndex; + this.endTexCoordIndex = endTexCoordIndex; + this.startNormalIndex = startNormalIndex; + this.endNormalIndex = endNormalIndex; + } + + public static DefaultObjEdge[] createEdges(DefaultObjFace faces) { + DefaultObjEdge[] edges = new DefaultObjEdge[faces.getNumVertices()-1]; + for (int i = 0; i < faces.getNumVertices()-1; i++) { + int startVertexIndex = faces.getVertexIndex(i); + int endVertexIndex = faces.getVertexIndex((i + 1)); + + int startNormalIndex = -1; + int endNormalIndex = -1; + if (faces.containsNormalIndices()) { + startNormalIndex = faces.getNormalIndex(i); + endNormalIndex = faces.getNormalIndex((i + 1)); + } + + int startTexCoordIndex = -1; + int endTexCoordIndex = -1; + if (faces.containsTexCoordIndices()) { + startTexCoordIndex = faces.getTexCoordIndex(i); + endTexCoordIndex = faces.getTexCoordIndex((i + 1)); + } + + edges[i] = new DefaultObjEdge(startVertexIndex, endVertexIndex, startTexCoordIndex, endTexCoordIndex, startNormalIndex, endNormalIndex); + } + return edges; + } +} diff --git a/core/src/net/sf/openrocket/file/wavefrontobj/DefaultObjFace.java b/core/src/net/sf/openrocket/file/wavefrontobj/DefaultObjFace.java index 09773de1c..4b4edb322 100644 --- a/core/src/net/sf/openrocket/file/wavefrontobj/DefaultObjFace.java +++ b/core/src/net/sf/openrocket/file/wavefrontobj/DefaultObjFace.java @@ -30,6 +30,8 @@ package net.sf.openrocket.file.wavefrontobj; import de.javagl.obj.ObjFace; +import java.util.Arrays; + /** * Default implementation of an ObjFace */ @@ -63,6 +65,12 @@ public final class DefaultObjFace implements ObjFace { this.normalIndices = normalIndices; } + public DefaultObjFace(DefaultObjFace face) { + this.vertexIndices = Arrays.copyOf(face.vertexIndices, face.vertexIndices.length); + this.texCoordIndices = face.texCoordIndices != null ? Arrays.copyOf(face.texCoordIndices, face.texCoordIndices.length) : null; + this.normalIndices = face.normalIndices != null ? Arrays.copyOf(face.normalIndices, face.normalIndices.length) : null; + } + @Override public boolean containsTexCoordIndices() { @@ -74,6 +82,18 @@ public final class DefaultObjFace implements ObjFace { return normalIndices != null; } + public int[] getVertexIndices() { + return vertexIndices; + } + + public int[] getTexCoordIndices() { + return texCoordIndices; + } + + public int[] getNormalIndices() { + return normalIndices; + } + @Override public int getVertexIndex(int number) { return this.vertexIndices[number]; diff --git a/core/src/net/sf/openrocket/file/wavefrontobj/DefaultObjGroup.java b/core/src/net/sf/openrocket/file/wavefrontobj/DefaultObjGroup.java index 8e9236687..1453141c3 100644 --- a/core/src/net/sf/openrocket/file/wavefrontobj/DefaultObjGroup.java +++ b/core/src/net/sf/openrocket/file/wavefrontobj/DefaultObjGroup.java @@ -71,10 +71,36 @@ public final class DefaultObjGroup implements ObjGroup { faces.add(face); } + public void addFaces(List faces) { + this.faces.addAll(faces); + } + + /** + * Remove the given face from this group + * + * @param face The face to remove + */ + public void removeFace(ObjFace face) { + faces.remove(face); + } + + /** + * Returns the faces in this group + * @return The faces in this group + */ public List getFaces() { return faces; } + /** + * Returns whether this group contains the given face + * @param face + * @return + */ + public boolean containsFace(ObjFace face) { + return faces.contains(face); + } + @Override public int getNumFaces() { return faces.size(); diff --git a/core/src/net/sf/openrocket/file/wavefrontobj/ObjUtils.java b/core/src/net/sf/openrocket/file/wavefrontobj/ObjUtils.java index 0931fc57d..8c9f03ce0 100644 --- a/core/src/net/sf/openrocket/file/wavefrontobj/ObjUtils.java +++ b/core/src/net/sf/openrocket/file/wavefrontobj/ObjUtils.java @@ -5,11 +5,16 @@ import de.javagl.obj.FloatTuples; import de.javagl.obj.Obj; import de.javagl.obj.ObjFace; import de.javagl.obj.ObjGroup; +import de.javagl.obj.ReadableObj; +import de.javagl.obj.WritableObj; import net.sf.openrocket.l10n.Translator; import net.sf.openrocket.startup.Application; import net.sf.openrocket.util.Coordinate; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Set; /** * Utility methods for working with {@link Obj} objects. @@ -71,6 +76,43 @@ public class ObjUtils { } } + public enum TriangulationMethod { + SIMPLE(trans.get("TriangulationMethod.SIMPLE"), trans.get("TriangulationMethod.SIMPLE.ttip"), "SIMPLE"), + DELAUNAY(trans.get("TriangulationMethod.DELAUNAY"), trans.get("TriangulationMethod.DELAUNAY.ttip"), "DELAUNAY"); + + private final String label; + private final String tooltip; + private final String exportLabel; + + TriangulationMethod(String label, String tooltip, String exportLabel) { + this.label = label; + this.tooltip = tooltip; + this.exportLabel = exportLabel; + } + + @Override + public String toString() { + return label; + } + + public String getTooltip() { + return tooltip; + } + + public String getExportLabel() { + return exportLabel; + } + + public static TriangulationMethod fromExportLabel(String exportLabel) { + for (TriangulationMethod tm : TriangulationMethod.values()) { + if (tm.getExportLabel().equals(exportLabel)) { + return tm; + } + } + return TriangulationMethod.DELAUNAY; + } + } + /** * Offset the indices by the given offset @@ -335,6 +377,80 @@ public class ObjUtils { return normalizeVector(new DefaultFloatTuple(x, y, z)); } + /** + * Calculates the normal vector of a triangle defined by three vertices. + * + * @param v1 The first vertex of the triangle. + * @param v2 The second vertex of the triangle. + * @param v3 The third vertex of the triangle. + * @return The normal vector of the triangle. + */ + public static FloatTuple calculateNormalVector(FloatTuple v1, FloatTuple v2, FloatTuple v3) { + FloatTuple u = subtractVectors(v2, v1); + FloatTuple v = subtractVectors(v3, v1); + + return normalizeVector(crossProduct(u, v)); + } + + /** + * Calculates the normal vector for a given face of the object. + * + * @param obj The object. + * @param face The face of the object for which to calculate the normal vector. + * @return The calculated normal vector. + */ + public static FloatTuple calculateNormalVector(DefaultObj obj, DefaultObjFace face) { + FloatTuple[] vertices = getVertices(obj, face); + return calculateNormalNewell(vertices); + } + + /** + * Calculates the normal of a polygon using the Newell's method. + * + * @param vertices a list of vertices representing the polygon + * @return the normalized normal vector of the polygon + */ + private static FloatTuple calculateNormalNewell(FloatTuple[] vertices) { + float x = 0f; + float y = 0f; + float z = 0f; + for (int i = 0; i < vertices.length; i++) { + FloatTuple current = vertices[i]; + FloatTuple next = vertices[(i + 1) % vertices.length]; + + x += (current.getY() - next.getY()) * (current.getZ() + next.getZ()); + y += (current.getZ() - next.getZ()) * (current.getX() + next.getX()); + z += (current.getX() - next.getX()) * (current.getY() + next.getY()); + } + return normalizeVector(new DefaultFloatTuple(x, y, z)); + } + + /** + * Subtracts two vectors. + * + * @param v1 the first vector + * @param v2 the second vector + * @return a new FloatTuple representing the subtraction of v2 from v1 + */ + public static FloatTuple subtractVectors(FloatTuple v1, FloatTuple v2) { + return new DefaultFloatTuple(v1.getX() - v2.getX(), v1.getY() - v2.getY(), v1.getZ() - v2.getZ()); + } + + /** + * Calculates the cross product of two vectors. + * + * @param v1 the first vector + * @param v2 the second vector + * @return the cross product of the given vectors + */ + public static FloatTuple crossProduct(FloatTuple v1, FloatTuple v2) { + return new DefaultFloatTuple( + v1.getY() * v2.getZ() - v1.getZ() * v2.getY(), + v1.getZ() * v2.getX() - v1.getX() * v2.getZ(), + v1.getX() * v2.getY() - v1.getY() * v2.getX() + ); + } + /** * Calculate the average of a list of vertices. * @param vertices The list of vertices @@ -499,4 +615,124 @@ public class ObjUtils { } return FloatTuples.create(fr, fg, fb); } + + /** + * Returns an array of FloatTuples representing the vertices of the object + * + * @param obj The DefaultObj object from which to retrieve the vertices + * @param vertexIndices An array of vertex indices specifying which vertices to retrieve + * @return An array of FloatTuples representing the vertices + */ + public static FloatTuple[] getVertices(DefaultObj obj, int[] vertexIndices) { + FloatTuple[] vertices = new FloatTuple[vertexIndices.length]; + for (int i = 0; i < vertexIndices.length; i++) { + vertices[i] = obj.getVertex(vertexIndices[i]); + } + return vertices; + } + + public static FloatTuple[] getVertices(DefaultObj obj, DefaultObjFace face) { + return getVertices(obj, face.getVertexIndices()); + } + + public static DefaultObjFace createFaceWithNewIndices(ObjFace face, int... n) { + int[] v = new int[n.length]; + int[] vt = null; + int[] vn = null; + + for (int i = 0; i < n.length; i++) { + v[i] = face.getVertexIndex(n[i]); + } + + if (face.containsTexCoordIndices()) { + vt = new int[n.length]; + + for (int i = 0; i < n.length; i++) { + vt[i] = face.getTexCoordIndex(n[i]); + } + } + + if (face.containsNormalIndices()) { + vn = new int[n.length]; + + for (int i = 0; i < n.length; i++) { + vn[i] = face.getNormalIndex(n[i]); + } + } + + return new DefaultObjFace(v, vt, vn); + } + + /** + * Copy all vertices, texture coordinates and normals from the input to the output + * @param input The input object + * @param output The output object + */ + public static void copyAllVertices(ReadableObj input, WritableObj output) { + for (int i = 0; i < input.getNumVertices(); i++) { + output.addVertex(input.getVertex(i)); + } + + for (int i = 0; i < input.getNumTexCoords(); i++) { + output.addTexCoord(input.getTexCoord(i)); + } + + for (int i = 0; i < input.getNumNormals(); i++) { + output.addNormal(input.getNormal(i)); + } + } + + /** + * Copy all faces and groups from the input to the output + * @param source The source object + * @param target The target object + */ + public static void copyAllFacesAndGroups(DefaultObj source, DefaultObj target) { + // Store the copied faces so we don't end up adding multiple copies of the same face + Map srcToTarFaceMap = new HashMap<>(); + + // Copy the groups (and their faces) + for (int i = 0; i < source.getNumGroups(); i++) { + DefaultObjGroup srcGroup = (DefaultObjGroup) source.getGroup(i); + DefaultObjGroup tarGroup = new DefaultObjGroup(srcGroup.getName()); + for (int j = 0; j < srcGroup.getNumFaces(); j++) { + DefaultObjFace srcFace = (DefaultObjFace) srcGroup.getFace(j); + DefaultObjFace storedFace = srcToTarFaceMap.get(srcFace); + + DefaultObjFace tarFace = storedFace != null ? storedFace : new DefaultObjFace(srcFace); + tarGroup.addFace(tarFace); + srcToTarFaceMap.put(srcFace, tarFace); + } + target.addGroup(tarGroup); + } + + // Copy the faces + for (int i = 0; i < source.getNumFaces(); i++) { + DefaultObjFace srcFace = (DefaultObjFace) source.getFace(i); + DefaultObjFace tarFace = srcToTarFaceMap.get(srcFace); + tarFace = tarFace != null ? tarFace : new DefaultObjFace(srcFace); + target.addFace(tarFace); + } + } + + /** + * Activates the groups and materials specified by the given face in the input object, + * and sets the active groups and material in the output object accordingly. + * + * @param input The input object from which to activate the groups and materials + * @param face The face containing the groups and materials to activate + * @param output The output object in which to set the active groups and materials + */ + public static void activateGroups(ReadableObj input, ObjFace face, WritableObj output) { + Set activatedGroupNames = input.getActivatedGroupNames(face); + if (activatedGroupNames != null) { + output.setActiveGroupNames(activatedGroupNames); + } + + String activatedMaterialGroupName = input.getActivatedMaterialGroupName(face); + if (activatedMaterialGroupName != null) { + output.setActiveMaterialGroupName(activatedMaterialGroupName); + } + + } } diff --git a/core/src/net/sf/openrocket/file/wavefrontobj/TriangulationHelper.java b/core/src/net/sf/openrocket/file/wavefrontobj/TriangulationHelper.java new file mode 100644 index 000000000..a286a53d7 --- /dev/null +++ b/core/src/net/sf/openrocket/file/wavefrontobj/TriangulationHelper.java @@ -0,0 +1,329 @@ +package net.sf.openrocket.file.wavefrontobj; + +import de.javagl.obj.FloatTuple; +import de.javagl.obj.ObjFace; +import net.sf.openrocket.util.MathUtil; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.triangulate.polygon.ConstrainedDelaunayTriangulator; +import org.locationtech.jts.triangulate.tri.Tri; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public abstract class TriangulationHelper { + private static final Logger log = LoggerFactory.getLogger(TriangulationHelper.class); + + public static DefaultObj simpleTriangulate(DefaultObj obj) { + return de.javagl.obj.ObjUtils.triangulate(obj, new DefaultObj()); + } + + /** + * Triangulates an OBJ object using constrained Delaunay triangulation. + * + * @param input The object to triangulate. + * @return A new object with the triangulated faces. + */ + public static DefaultObj constrainedDelaunayTriangulate(DefaultObj input) { + // Create a new OBJ that will contain the triangulated faces, and copy all the vertices and MTL file names from the original OBJ + DefaultObj output = input.clone(false); + + for (ObjFace face : input.getFaces()) { + ObjUtils.activateGroups(input, face, output); + if (face.getNumVertices() == 3) { + output.addFace(face); + continue; + } else if (face.getNumVertices() < 3) { + log.debug("Face has less than 3 vertices, skipping"); + continue; + } + + // Generate the new triangulated faces + List newFaces = generateCDTFaces(input, (DefaultObjFace) face); + + // Add the triangulated faces + for (ObjFace newFace : newFaces) { + output.addFace(newFace); + } + } + + return output; + } + + /** + * Generates constrained Delaunay triangulation faces based on a given object and face. + * + * @param obj The input object containing 3D polygon data. + * @param face The specific face of the object to be triangulated. + * @return A list of generated object faces representing the triangulated faces. + */ + public static List generateCDTFaces(DefaultObj obj, DefaultObjFace face) { + // Retrieve the vertex mapping to normal indices and texture coordinate indices + Map vertexToNormalMap = mapVertexIndicesToNormalIndices(face); + Map vertexToTexCoordMap = mapVertexIndicesToTexCoordIndices(face); + + // Calculate the face normal + Coordinate normal = vertexToCoordinate(ObjUtils.calculateNormalVector(obj, face)); + + // Project the 3D face to a 2D polygon with only X and Y coordinates. + // This is necessary because the JTS library only works with 2D polygons for triangulation. + PolygonWithOriginalIndices polygonWithIndices = createProjectedPolygon(obj, face, normal); + Polygon polygon = polygonWithIndices.polygon(); + Map vertexIndexMap = polygonWithIndices.vertexIndexMap(); + + // Triangulate the polygon + ConstrainedDelaunayTriangulator triangulator = new ConstrainedDelaunayTriangulator(polygon); + List triangles = triangulator.getTriangles(); + + // Create the new faces to add to the OBJ + List newFaces = new ArrayList<>(); + for (Tri tri : triangles) { + // Map the 2D triangle vertices back to the original vertex indices + int[] vertexIndices = new int[3]; + for (int i = 0; i < 3; i++) { + Coordinate coord = tri.getCoordinate(i); + vertexIndices[i] = getVertexIndexFromCoord(vertexIndexMap, coord); + } + + // Calculate the normal of the triangle, and verify that it has the same orientation as the original face + // If it does not, invert the vertex order to ensure the normal points in the same direction + // This is necessary for correct face culling. + Coordinate triangleNormal = calculateNormal( + vertexToCoordinate(obj.getVertex(vertexIndices[0])), + vertexToCoordinate(obj.getVertex(vertexIndices[1])), + vertexToCoordinate(obj.getVertex(vertexIndices[2]))); + if (normalsHaveDifferentDirection(triangleNormal, normal)) { + int temp = vertexIndices[0]; + vertexIndices[0] = vertexIndices[2]; + vertexIndices[2] = temp; + } + + // Add the new face to the list + if (vertexIndices[0] != -1 && vertexIndices[1] != -1 && vertexIndices[2] != -1) { + // Map the vertex indices to normal and texture coordinate indices + int[] normalIndices = vertexToNormalMap != null ? + new int[] {vertexToNormalMap.get(vertexIndices[0]), vertexToNormalMap.get(vertexIndices[1]), vertexToNormalMap.get(vertexIndices[2])} + : null; + int[] texCoordIndices = vertexToTexCoordMap != null ? + new int[] {vertexToTexCoordMap.get(vertexIndices[0]), vertexToTexCoordMap.get(vertexIndices[1]), vertexToTexCoordMap.get(vertexIndices[2])} + : null; + + // Create and add the new face + DefaultObjFace newFace = new DefaultObjFace(vertexIndices, texCoordIndices, normalIndices); + newFaces.add(newFace); + } + } + + return newFaces; + } + + /** + * Projects 3D coordinates of a polygon onto a 2D plane for further processing with JTS. + * The projection minimizes distortion by aligning the polygon's normal with the Z-axis. + * + * @param obj The input OBJ containing 3D polygon data. + * @param face The specific face of the OBJ to be projected and triangulated. + * @param normal the normal of the face to determine its orientation in 3D space + * @return A polygon in 2D space suitable for use with JTS. + */ + private static PolygonWithOriginalIndices createProjectedPolygon(DefaultObj obj, DefaultObjFace face, Coordinate normal) { + // Create a list for storing the projected 2D coordinates + List projectedCoords = new ArrayList<>(); + Map vertexIndexMap = new HashMap<>(); + + // Project each vertex onto the 2D plane + for (int vertexIndex : face.getVertexIndices()) { + FloatTuple vertex = obj.getVertex(vertexIndex); + Coordinate3D originalCoord = new Coordinate3D(vertexToCoordinate(vertex)); + Coordinate projectedCoord = projectVertexOntoXYPlane(originalCoord, normal); + projectedCoord = new Coordinate(projectedCoord.x, projectedCoord.y); + projectedCoords.add(projectedCoord); + + vertexIndexMap.put(new Coordinate3D(projectedCoord), vertexIndex); + } + + // Ensure polygon closure by repeating the first coordinate at the end if necessary + if (!projectedCoords.isEmpty() && !projectedCoords.get(0).equals3D(projectedCoords.get(projectedCoords.size() - 1))) { + projectedCoords.add(projectedCoords.get(0)); + } + + // Create the polygon + GeometryFactory factory = new GeometryFactory(); + Polygon polygon = factory.createPolygon(projectedCoords.toArray(new Coordinate[0])); + + return new PolygonWithOriginalIndices(polygon, vertexIndexMap); + } + + /** + * Projects a vertex onto a plane by rotating it so the face normal aligns with the Z-axis. + * + * @param vertex The 3D vertex to project. + * @param normal The normal vector of the polygon's face. + * @return The projected 2D coordinate of the vertex. + */ + private static Coordinate projectVertexOntoXYPlane(Coordinate3D vertex, Coordinate normal) { + // If the normal is a zero vector, the polygon is degenerate and cannot be projected + if (MathUtil.equals(normal.x, 0) && MathUtil.equals(normal.y, 0) && MathUtil.equals(normal.z, 0)) { + throw new IllegalArgumentException("Cannot project a degenerate polygon onto a 2D plane"); + } + // If the normal is parallel to the Z-axis, the polygon is already 2D + if (MathUtil.equals(normal.x, 0) && MathUtil.equals(normal.y, 0)) { + return new Coordinate(vertex.coordinate().x, vertex.coordinate().y); + } + + Coordinate u = crossProduct(normal, new Coordinate(0, 0, 1)); + Coordinate w = crossProduct(normal, u); + + double x2D = dotProduct(vertex.coordinate(), u); + double y2D = dotProduct(vertex.coordinate(), w); + + return new Coordinate(x2D, y2D); + } + + /** + * Maps the vertex indices of a face to the normal indices of the same face. + * + * @param face The face for which to map the vertex indices to normal indices. + * @return A map that maps the vertex indices to the normal indices, or null if the face does not contain normal indices. + */ + private static Map mapVertexIndicesToNormalIndices(DefaultObjFace face) { + int[] normalIndices = face.getNormalIndices(); + if (normalIndices == null) { + return null; + } + + Map vertexToNormalMap = new HashMap<>(); + int[] vertexIndices = face.getVertexIndices(); + for (int i = 0; i < vertexIndices.length; i++) { + vertexToNormalMap.put(vertexIndices[i], normalIndices[i]); + } + return vertexToNormalMap; + } + + /** + * Maps vertex indices to texture coordinate indices. + * + * @param face The face object containing the vertex and texture coordinate indices. + * @return A map that maps vertex indices to texture coordinate indices, or null if the face does + * not contain texture coordinate indices. + */ + private static Map mapVertexIndicesToTexCoordIndices(DefaultObjFace face) { + int[] texCoordIndices = face.getTexCoordIndices(); + if (texCoordIndices == null) { + return null; + } + + Map vertexToTexCoordMap = new HashMap<>(); + int[] vertexIndices = face.getVertexIndices(); + for (int i = 0; i < vertexIndices.length; i++) { + vertexToTexCoordMap.put(vertexIndices[i], texCoordIndices[i]); + } + return vertexToTexCoordMap; + } + + /** + * Converts a FloatTuple vertex to a Coordinate object. + * + * @param vertex The vertex to convert. + * @return The converted Coordinate object. + */ + private static Coordinate vertexToCoordinate(FloatTuple vertex) { + return new Coordinate(vertex.getX(), vertex.getY(), vertex.getZ()); + } + + + /** + * Retrieves the vertex index from the given coordinate in the vertex index map. + * + * @param vertexIndexMap The map containing the vertex coordinates and their corresponding indices. + * @param coord The coordinate to retrieve the vertex index for. + * @return The vertex index if the coordinate is found in the map, or -1 if not found. + */ + private static int getVertexIndexFromCoord(Map vertexIndexMap, Coordinate coord) { + for (Map.Entry entry : vertexIndexMap.entrySet()) { + Coordinate key = entry.getKey().coordinate(); + if (key.equals3D(coord)) { + return entry.getValue(); + } + } + return -1; + } + + /** + * Determines whether two normals have different directions. + * + * @param normal1 The first normal. + * @param normal2 The second normal. + * @return true if the two normals have different directions, false otherwise. + */ + private static boolean normalsHaveDifferentDirection(Coordinate normal1, Coordinate normal2) { + return dotProduct(normal1, normal2) < 0; + } + + public static Coordinate calculateNormal(Coordinate p1, Coordinate p2, Coordinate p3) { + Coordinate u = subtract(p2, p1); + Coordinate v = subtract(p3, p1); + + return normalize(crossProduct(u, v)); + } + + // ==================================== Basic Vector Math ==================================== + + private static Coordinate crossProduct(Coordinate v1, Coordinate v2) { + return new Coordinate( + v1.y * v2.z - v1.z * v2.y, + v1.z * v2.x - v1.x * v2.z, + v1.x * v2.y - v1.y * v2.x + ); + } + + + private static double dotProduct(Coordinate v1, Coordinate v2) { + return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z; + } + + + private static Coordinate subtract(Coordinate v1, Coordinate v2) { + return new Coordinate(v1.x - v2.x, v1.y - v2.y, v1.z - v2.z); + } + + private static Coordinate normalize(Coordinate vector) { + double magnitude = magnitude(vector); + if (magnitude == 0) { + return new Coordinate(0, 0, 0); + } + + return new Coordinate(vector.x / magnitude, vector.y / magnitude, vector.z / magnitude); + } + + private static double magnitude(Coordinate vector) { + return Math.sqrt(dotProduct(vector, vector)); + } + + // ==================================== Helper classes ==================================== + + // Helper class to wrap a Polygon and its original vertex indices + private record PolygonWithOriginalIndices(Polygon polygon, Map vertexIndexMap) { } + + // Helper class to wrap Coordinate and override equals and hashCode to account for all 3 dimensions + private record Coordinate3D(Coordinate coordinate) { + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Coordinate3D that = (Coordinate3D) o; + return coordinate.equals3D(that.coordinate); + } + + @Override + public int hashCode() { + return Objects.hash(coordinate.x, coordinate.y, coordinate.z); + } + } +} diff --git a/core/src/net/sf/openrocket/file/wavefrontobj/export/OBJExportOptions.java b/core/src/net/sf/openrocket/file/wavefrontobj/export/OBJExportOptions.java index e89b108c2..1a987dd6d 100644 --- a/core/src/net/sf/openrocket/file/wavefrontobj/export/OBJExportOptions.java +++ b/core/src/net/sf/openrocket/file/wavefrontobj/export/OBJExportOptions.java @@ -33,6 +33,10 @@ public class OBJExportOptions { * If true, triangulate all faces (convert quads and higher-order polygons to triangles) */ private boolean triangulate; + /** + * The method to use for triangulation. + */ + private ObjUtils.TriangulationMethod triangulationMethod; /** * If true, use sRGB colors instead of linear color space. */ @@ -94,6 +98,14 @@ public class OBJExportOptions { this.triangulate = triangulate; } + public ObjUtils.TriangulationMethod getTriangulationMethod() { + return triangulationMethod; + } + + public void setTriangulationMethod(ObjUtils.TriangulationMethod triangulationMethod) { + this.triangulationMethod = triangulationMethod; + } + public boolean isExportAppearance() { return exportAppearance; } diff --git a/core/src/net/sf/openrocket/file/wavefrontobj/export/OBJExporterFactory.java b/core/src/net/sf/openrocket/file/wavefrontobj/export/OBJExporterFactory.java index 11a607146..b11f90f5d 100644 --- a/core/src/net/sf/openrocket/file/wavefrontobj/export/OBJExporterFactory.java +++ b/core/src/net/sf/openrocket/file/wavefrontobj/export/OBJExporterFactory.java @@ -8,6 +8,7 @@ import net.sf.openrocket.file.wavefrontobj.DefaultMtl; import net.sf.openrocket.file.wavefrontobj.DefaultMtlWriter; import net.sf.openrocket.file.wavefrontobj.DefaultObj; import net.sf.openrocket.file.wavefrontobj.ObjUtils; +import net.sf.openrocket.file.wavefrontobj.TriangulationHelper; import net.sf.openrocket.file.wavefrontobj.export.components.BodyTubeExporter; import net.sf.openrocket.file.wavefrontobj.export.components.FinSetExporter; import net.sf.openrocket.file.wavefrontobj.export.components.LaunchLugExporter; @@ -174,7 +175,14 @@ public class OBJExporterFactory { // Triangulate mesh if (this.options.isTriangulate()) { - obj = de.javagl.obj.ObjUtils.triangulate(obj, new DefaultObj()); + ObjUtils.TriangulationMethod triangulationMethod = this.options.getTriangulationMethod(); + if (triangulationMethod == ObjUtils.TriangulationMethod.DELAUNAY) { + obj = TriangulationHelper.constrainedDelaunayTriangulate(obj); + } else if (triangulationMethod == ObjUtils.TriangulationMethod.SIMPLE) { + obj = TriangulationHelper.simpleTriangulate(obj); + } else { + throw new IllegalArgumentException("Unsupported triangulation method: " + triangulationMethod); + } } // Remove position offset diff --git a/core/src/net/sf/openrocket/file/wavefrontobj/export/shapes/PolygonExporter.java b/core/src/net/sf/openrocket/file/wavefrontobj/export/shapes/PolygonExporter.java index e380b3408..0c348b994 100644 --- a/core/src/net/sf/openrocket/file/wavefrontobj/export/shapes/PolygonExporter.java +++ b/core/src/net/sf/openrocket/file/wavefrontobj/export/shapes/PolygonExporter.java @@ -3,6 +3,7 @@ package net.sf.openrocket.file.wavefrontobj.export.shapes; import com.sun.istack.NotNull; import net.sf.openrocket.file.wavefrontobj.CoordTransform; import net.sf.openrocket.file.wavefrontobj.DefaultObj; +import net.sf.openrocket.file.wavefrontobj.DefaultObjEdge; import net.sf.openrocket.file.wavefrontobj.DefaultObjFace; import net.sf.openrocket.file.wavefrontobj.ObjUtils; diff --git a/core/src/net/sf/openrocket/startup/Preferences.java b/core/src/net/sf/openrocket/startup/Preferences.java index 17d70440e..30a3e1822 100644 --- a/core/src/net/sf/openrocket/startup/Preferences.java +++ b/core/src/net/sf/openrocket/startup/Preferences.java @@ -128,6 +128,7 @@ public abstract class Preferences implements ChangeSource { private static final String OBJ_EXPORT_AS_SEPARATE_FILES = "ExportAsSeparateFiles"; private static final String OBJ_REMOVE_OFFSET = "RemoveOffset"; private static final String OBJ_TRIANGULATE = "Triangulate"; + private static final String OBJ_TRIANGULATION_METHOD = "TriangulationMethod"; private static final String OBJ_SRGB = "sRGB"; private static final String OBJ_LOD = "LOD"; private static final String OBJ_SCALING = "Scaling"; @@ -1051,6 +1052,7 @@ public abstract class Preferences implements ChangeSource { objExportOptionsNode.putBoolean(OBJ_EXPORT_AS_SEPARATE_FILES, options.isExportAsSeparateFiles()); objExportOptionsNode.putBoolean(OBJ_REMOVE_OFFSET, options.isRemoveOffset()); objExportOptionsNode.putBoolean(OBJ_TRIANGULATE, options.isTriangulate()); + objExportOptionsNode.put(OBJ_TRIANGULATION_METHOD, options.getTriangulationMethod().getExportLabel()); objExportOptionsNode.putBoolean(OBJ_SRGB, options.isUseSRGB()); objExportOptionsNode.putFloat(OBJ_SCALING, options.getScaling()); @@ -1081,6 +1083,9 @@ public abstract class Preferences implements ChangeSource { options.setExportAsSeparateFiles(objExportOptionsNode.getBoolean(OBJ_EXPORT_AS_SEPARATE_FILES, false)); options.setRemoveOffset(objExportOptionsNode.getBoolean(OBJ_REMOVE_OFFSET, true)); options.setTriangulate(objExportOptionsNode.getBoolean(OBJ_TRIANGULATE, true)); + options.setTriangulationMethod(ObjUtils.TriangulationMethod.fromExportLabel( + objExportOptionsNode.get(OBJ_TRIANGULATION_METHOD, ObjUtils.TriangulationMethod.DELAUNAY.getExportLabel()) + )); options.setUseSRGB(objExportOptionsNode.getBoolean(OBJ_SRGB, false)); options.setScaling(objExportOptionsNode.getFloat(OBJ_SCALING, 1000)); diff --git a/core/test/net/sf/openrocket/file/wavefrontobj/export/OBJExporterFactoryTest.java b/core/test/net/sf/openrocket/file/wavefrontobj/export/OBJExporterFactoryTest.java index af5f5dd5c..769be8698 100644 --- a/core/test/net/sf/openrocket/file/wavefrontobj/export/OBJExporterFactoryTest.java +++ b/core/test/net/sf/openrocket/file/wavefrontobj/export/OBJExporterFactoryTest.java @@ -175,6 +175,7 @@ public class OBJExporterFactoryTest { bodyTube.setFilled(true); options.setTriangulate(true); + options.setTriangulationMethod(ObjUtils.TriangulationMethod.DELAUNAY); options.setRemoveOffset(false); options.setExportAppearance(true); options.setScaling(1000); @@ -193,6 +194,14 @@ public class OBJExporterFactoryTest { //// Just hope for no exceptions :) assertEquals(warnings.size(), 1); + // Test simple triangulation + options.setTriangulationMethod(ObjUtils.TriangulationMethod.SIMPLE); + + exporterFactory = new OBJExporterFactory(components, rocket.getSelectedConfiguration(), tempFile.toFile(), options, warnings); + exporterFactory.doExport(); + //// Just hope for no exceptions :) + assertEquals(warnings.size(), 1); + // Clean up Files.delete(tempFile); } diff --git a/swing/build.xml b/swing/build.xml index af8a1d3f5..287b1e764 100644 --- a/swing/build.xml +++ b/swing/build.xml @@ -145,6 +145,7 @@ + diff --git a/swing/src/net/sf/openrocket/file/wavefrontobj/OBJOptionChooser.java b/swing/src/net/sf/openrocket/file/wavefrontobj/OBJOptionChooser.java index ec42ad0a0..84e4ed174 100644 --- a/swing/src/net/sf/openrocket/file/wavefrontobj/OBJOptionChooser.java +++ b/swing/src/net/sf/openrocket/file/wavefrontobj/OBJOptionChooser.java @@ -15,11 +15,13 @@ import net.sf.openrocket.unit.UnitGroup; import javax.swing.AbstractButton; import javax.swing.BorderFactory; +import javax.swing.DefaultListCellRenderer; import javax.swing.JButton; import javax.swing.JCheckBox; import javax.swing.JComboBox; import javax.swing.JComponent; import javax.swing.JLabel; +import javax.swing.JList; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JSeparator; @@ -29,6 +31,7 @@ import javax.swing.UIManager; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import java.awt.Color; +import java.awt.Component; import java.awt.Window; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; @@ -53,6 +56,8 @@ public class OBJOptionChooser extends JPanel { private final JCheckBox exportAsSeparateFiles; private final JCheckBox removeOffset; private final JCheckBox triangulate; + private final JLabel tmLabel; + private final JComboBox triangulationMethod; private final JCheckBox sRGB; private final JComboBox LOD; private final DoubleModel scalingModel; @@ -223,11 +228,15 @@ public class OBJOptionChooser extends JPanel { @Override public void itemStateChanged(ItemEvent e) { if (e.getStateChange() == ItemEvent.SELECTED) { + tmLabel.setEnabled(true); + triangulationMethod.setEnabled(true); // Disable the export appearance, it is not supported in combination with triangulate exportAppearance.setEnabled(false); exportAppearance.setSelected(false); exportAppearance.setToolTipText(trans.get("OBJOptionChooser.checkbox.exportAppearance.ttip.triangulate")); } else { + tmLabel.setEnabled(false); + triangulationMethod.setEnabled(false); // Re-enable exportAppearance.setEnabled(true); exportAppearance.setSelected(opts.isExportAppearance()); @@ -236,6 +245,17 @@ public class OBJOptionChooser extends JPanel { } }); + //// Triangulation method + this.tmLabel = new JLabel(trans.get("OBJOptionChooser.lbl.triangulationMethod")); + this.tmLabel.setToolTipText(trans.get("OBJOptionChooser.lbl.triangulationMethod.ttip")); + advancedOptionsPanel.add(this.tmLabel, "spanx, split 2"); + this.triangulationMethod = new JComboBox<>(ObjUtils.TriangulationMethod.values()); + this.triangulationMethod.setToolTipText(trans.get("OBJOptionChooser.lbl.triangulationMethod.ttip")); + this.triangulationMethod.setRenderer(new TriangulationMethodRenderer()); + destroyTheMagic(triangulationMethod); + addOptimizationListener(triangulationMethod); + advancedOptionsPanel.add(triangulationMethod, "growx, wrap unrel"); + //// Level of detail JLabel LODLabel = new JLabel(trans.get("OBJOptionChooser.lbl.LevelOfDetail")); LODLabel.setToolTipText(trans.get("OBJOptionChooser.lbl.LevelOfDetail.ttip")); @@ -394,6 +414,7 @@ public class OBJOptionChooser extends JPanel { if (!opts.isTriangulate()) { this.exportAppearance.setSelected(opts.isExportAppearance()); } + this.triangulationMethod.setSelectedItem(opts.getTriangulationMethod()); this.sRGB.setSelected(opts.isUseSRGB()); this.scalingModel.setValue(opts.getScaling()); @@ -422,6 +443,7 @@ public class OBJOptionChooser extends JPanel { opts.setExportAsSeparateFiles(exportAsSeparateFiles.isSelected()); opts.setRemoveOffset(removeOffset.isSelected()); opts.setTriangulate(triangulate.isSelected()); + opts.setTriangulationMethod((ObjUtils.TriangulationMethod) triangulationMethod.getSelectedItem()); opts.setUseSRGB(sRGB.isSelected()); opts.setScaling((float) scalingModel.getValue()); opts.setLOD((ObjUtils.LevelOfDetail) LOD.getSelectedItem()); @@ -441,6 +463,7 @@ public class OBJOptionChooser extends JPanel { options.setRemoveOffset(true); options.setScaling(1000); options.setTriangulate(true); + options.setTriangulationMethod(ObjUtils.TriangulationMethod.DELAUNAY); options.setLOD(ObjUtils.LevelOfDetail.HIGH_QUALITY); loadOptions(options); @@ -453,6 +476,7 @@ public class OBJOptionChooser extends JPanel { */ private boolean isOptimizedFor3DPrinting(OBJExportOptions options) { return !options.isExportMotors() && !options.isExportAppearance() && options.isTriangulate() && + options.getTriangulationMethod() == ObjUtils.TriangulationMethod.DELAUNAY && options.getLOD() == ObjUtils.LevelOfDetail.HIGH_QUALITY && options.isRemoveOffset() && options.getScaling() == 1000; } @@ -581,6 +605,22 @@ public class OBJOptionChooser extends JPanel { } } + private static class TriangulationMethodRenderer extends DefaultListCellRenderer { + + @Override + public Component getListCellRendererComponent(JList list, Object value, + int index, boolean isSelected, boolean cellHasFocus) { + + JComponent comp = (JComponent) super.getListCellRendererComponent(list, + value, index, isSelected, cellHasFocus); + + if (index > -1 && value instanceof ObjUtils.TriangulationMethod) { + list.setToolTipText(((ObjUtils.TriangulationMethod) value).getTooltip()); + } + return comp; + } + } + /*private void coordTransComboAction(ItemEvent e, JComboBox otherCombo) { if (e.getStateChange() != ItemEvent.SELECTED) { return; diff --git a/swing/src/net/sf/openrocket/gui/dialogs/AboutDialog.java b/swing/src/net/sf/openrocket/gui/dialogs/AboutDialog.java index f3dd1d329..840fd8279 100644 --- a/swing/src/net/sf/openrocket/gui/dialogs/AboutDialog.java +++ b/swing/src/net/sf/openrocket/gui/dialogs/AboutDialog.java @@ -80,6 +80,7 @@ public class AboutDialog extends JDialog { "Darklaf (dark theme)" + href("https://github.com/weisJ/darklaf", true, true) + "
" + "jSystemThemeDetector" + href("https://github.com/Dansoftowner/jSystemThemeDetector", true, true) + "
" + "Obj" + href("https://github.com/javagl/Obj", true, true) + "
" + + "JTS" + href("https://github.com/locationtech/jts", true, true) + "
" + "
" + "OpenRocket gratefully acknowledges our use of the following databases:
" + "
" +