From 4fc3901f0035f430464aeb6b82a496637a836034 Mon Sep 17 00:00:00 2001 From: SiboVG Date: Sat, 19 Aug 2023 19:43:04 +0200 Subject: [PATCH] Add textures (WIP) --- core/resources/l10n/messages.properties | 2 + .../defaults/ResourceDecalImage.java | 12 ++ .../export/AppearanceExporter.java | 107 +++++++++++++----- .../wavefrontobj/export/OBJExportOptions.java | 14 ++- .../export/OBJExporterFactory.java | 40 +++++-- .../export/components/MassObjectExporter.java | 52 +++++++-- .../export/components/MotorExporter.java | 2 +- .../export/components/TransitionExporter.java | 106 +++++++++++++---- .../export/shapes/CylinderExporter.java | 41 ++++++- .../export/shapes/PolygonExporter.java | 76 ++++++++++++- .../src/net/sf/openrocket/util/FileUtils.java | 10 ++ .../export/OBJExporterFactoryTest.java | 23 ++-- .../file/wavefrontobj/OBJOptionChooser.java | 7 ++ .../sf/openrocket/gui/main/BasicFrame.java | 2 +- 14 files changed, 404 insertions(+), 90 deletions(-) diff --git a/core/resources/l10n/messages.properties b/core/resources/l10n/messages.properties index 0f129b2c2..fd8bbc143 100644 --- a/core/resources/l10n/messages.properties +++ b/core/resources/l10n/messages.properties @@ -1498,6 +1498,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.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.LevelOfDetail = Level of detail: OBJOptionChooser.lbl.LevelOfDetail.ttip = Select the desired level of detail of the geometry export. diff --git a/core/src/net/sf/openrocket/appearance/defaults/ResourceDecalImage.java b/core/src/net/sf/openrocket/appearance/defaults/ResourceDecalImage.java index b1a53853c..9bc7a4fbe 100644 --- a/core/src/net/sf/openrocket/appearance/defaults/ResourceDecalImage.java +++ b/core/src/net/sf/openrocket/appearance/defaults/ResourceDecalImage.java @@ -1,11 +1,15 @@ package net.sf.openrocket.appearance.defaults; +import java.io.BufferedOutputStream; import java.io.File; import java.io.FileNotFoundException; +import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import net.sf.openrocket.appearance.DecalImage; +import net.sf.openrocket.util.FileUtils; import net.sf.openrocket.util.StateChangeListener; /** @@ -46,6 +50,14 @@ public class ResourceDecalImage implements DecalImage { @Override public void exportImage(File file) throws IOException { + InputStream is; + is = getBytes(); + OutputStream os = new BufferedOutputStream(new FileOutputStream(file)); + + FileUtils.copy(is, os); + + is.close(); + os.close(); } @Override diff --git a/core/src/net/sf/openrocket/file/wavefrontobj/export/AppearanceExporter.java b/core/src/net/sf/openrocket/file/wavefrontobj/export/AppearanceExporter.java index 4a2e0eb05..5e8236c6b 100644 --- a/core/src/net/sf/openrocket/file/wavefrontobj/export/AppearanceExporter.java +++ b/core/src/net/sf/openrocket/file/wavefrontobj/export/AppearanceExporter.java @@ -1,17 +1,22 @@ package net.sf.openrocket.file.wavefrontobj.export; -import de.javagl.obj.FloatTuple; import net.sf.openrocket.appearance.Appearance; import net.sf.openrocket.appearance.Decal; +import net.sf.openrocket.appearance.DecalImage; import net.sf.openrocket.appearance.defaults.DefaultAppearance; -import net.sf.openrocket.file.wavefrontobj.DefaultFloatTuple; import net.sf.openrocket.file.wavefrontobj.DefaultMtl; import net.sf.openrocket.file.wavefrontobj.DefaultObj; import net.sf.openrocket.file.wavefrontobj.DefaultTextureOptions; import net.sf.openrocket.rocketcomponent.RocketComponent; import net.sf.openrocket.util.Color; import net.sf.openrocket.util.Coordinate; +import net.sf.openrocket.util.FileUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; /** @@ -21,21 +26,30 @@ import java.util.List; */ public class AppearanceExporter { private final DefaultObj obj; - private final RocketComponent component; + private final Appearance appearance; + private final File file; + private final OBJExportOptions options; private final String materialName; private final List materials; + private static final Logger log = LoggerFactory.getLogger(AppearanceExporter.class); + /** * Export the appearance of a rocket component * NOTE: you still have to call {@link #doExport()} to actually export the appearance. * @param obj The obj file that will use the material - * @param component The component to export the appearance of + * @param appearance The appearance to export + * @param file The file that the OBJ is exported to + * @param options The options to use for exporting the OBJ * @param materialName The name of the material to generate * @param materials The list of materials to add the new material(s) to */ - public AppearanceExporter(DefaultObj obj, RocketComponent component, String materialName, List materials) { + public AppearanceExporter(DefaultObj obj, Appearance appearance, File file, OBJExportOptions options, + String materialName, List materials) { this.obj = obj; - this.component = component; + this.appearance = appearance; + this.file = file; + this.options = options; this.materialName = materialName; this.materials = materials; } @@ -48,22 +62,18 @@ public class AppearanceExporter { obj.setActiveMaterialGroupName(materialName); DefaultMtl material = new DefaultMtl(materialName); - // Get the component appearance - Appearance appearance = component.getAppearance(); - if (appearance == null) { - appearance = DefaultAppearance.getDefaultAppearance(component); - } - // Apply coloring - applyColoring(appearance, material); + applyColoring(appearance, material, options); // Apply texture applyTexture(appearance, material); materials.add(material); + + // TODO: default back to default material? } - private static void applyTexture(Appearance appearance, DefaultMtl material) { + private void applyTexture(Appearance appearance, DefaultMtl material) { Decal texture = appearance.getTexture(); if (texture == null) { return; @@ -71,39 +81,74 @@ public class AppearanceExporter { final DefaultTextureOptions textureOptions = new DefaultTextureOptions(); - // TODO: file name (save externally if saved inside .ork) - //String filePath = texture.getImage().getDecalFile().getAbsolutePath(); - String filePath = "/Users/SiboVanGool/Downloads/hello.jpeg"; - textureOptions.setFileName(filePath); + // The decal file is stored inside the .ork, so first export it to the export directory + final File decalFile; + try { + String exportDir = file.getParent(); + String fileName = FileUtils.removeExtension(file.getName()); + Path decalDir = Path.of(exportDir, fileName + "_img"); + Files.createDirectories(decalDir); - // Texture offset - final Coordinate origin = texture.getOffset(); - Float origX = (float) origin.x; - Float origY = (float) origin.y; - textureOptions.setO(origX, origY, 0f); + DecalImage decal = texture.getImage(); + String decalName = FileUtils.getFileNameFromPath(decal.getName()); + decalFile = new File(decalDir.toString(), decalName); // TODO: should name be unique? + decalFile.createNewFile(); // TODO: check if you want to overwrite? + decal.exportImage(decalFile); + log.info("Exported decal image to {}", decalFile.getAbsolutePath()); + } catch (Exception e) { + log.error("Failed to export decal image", e); + return; + } + + textureOptions.setFileName(decalFile.getAbsolutePath()); // Texture scale final Coordinate scale = texture.getScale(); - Float scaleX = (float) scale.x; - Float scaleY = (float) scale.y; + float scaleX = (float) scale.x; + float scaleY = (float) scale.y; textureOptions.setS(scaleX, scaleY, 1f); - // TODO: rotation + // Texture offset + final Coordinate origin = texture.getOffset(); + float origX = (float) origin.x; + float origY = (float) (origin.y - 1 - 1/scaleY); // Need an extra offset because the texture scale origin is different in OR + textureOptions.setO(origX, origY, 0f); + + // Texture rotation is not possible in MTL... + + // Texture repeat (not very extensive in MTL...) + Decal.EdgeMode edgeMode = texture.getEdgeMode(); + switch (edgeMode) { + case REPEAT, MIRROR -> textureOptions.setClamp(false); + default -> textureOptions.setClamp(true); + } // Apply the texture material.setMapKdOptions(textureOptions); } - private static void applyColoring(Appearance appearance, DefaultMtl material) { + private static void applyColoring(Appearance appearance, DefaultMtl material, OBJExportOptions options) { Color color = appearance.getPaint(); - final float r = color.getRed()/255f; - final float g = color.getGreen()/255f; - final float b = color.getBlue()/255f; + final float r = convertColorToFloat(color.getRed(), options.isUseSRGB()); + final float g = convertColorToFloat(color.getGreen(), options.isUseSRGB()); + final float b = convertColorToFloat(color.getBlue(), options.isUseSRGB()); material.setKd(r, g, b); // Diffuse color material.setKa(0f, 0f, 0f); // No emission - material.setKs(1f, 1f, 1f); // Use white specular highlights + material.setKs(.25f, .25f, .25f); // Not too strong specular highlights material.setD(color.getAlpha()/255f); // Opacity material.setNs((float) appearance.getShine() * 750); // Shine (max is 1000, but this too strong compared to OpenRocket's max) material.setIllum(2); // Use Phong reflection (specular highlights etc.) } + + private static float convertColorToFloat(int color, boolean sRGB) { + float convertedColor = color / 255f; + if (sRGB) { + convertedColor = linearTosRGB(convertedColor); + } + return convertedColor; + } + + private static float linearTosRGB(float linear) { + return (float) Math.pow(linear, 2.2); + } } 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 605b1fd40..55f15713b 100644 --- a/core/src/net/sf/openrocket/file/wavefrontobj/export/OBJExportOptions.java +++ b/core/src/net/sf/openrocket/file/wavefrontobj/export/OBJExportOptions.java @@ -27,6 +27,10 @@ public class OBJExportOptions { * If true, triangulate all faces (convert quads and higher-order polygons to triangles) */ private boolean triangulate; + /** + * If true, use sRGB colors instead of linear color space. + */ + private boolean useSRGB; /** * The level of detail to use for the export (e.g. low-quality, normal quality...). */ @@ -41,8 +45,6 @@ public class OBJExportOptions { */ private float scaling; - // TODO: scaling (to mm = x1000, or SI units) - public OBJExportOptions(Rocket rocket) { this.exportChildren = false; this.exportAppearance = false; @@ -117,4 +119,12 @@ public class OBJExportOptions { public void setScaling(float scaling) { this.scaling = scaling; } + + public boolean isUseSRGB() { + return useSRGB; + } + + public void setUseSRGB(boolean useSRGB) { + this.useSRGB = useSRGB; + } } 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 cd05b8e71..14259bc81 100644 --- a/core/src/net/sf/openrocket/file/wavefrontobj/export/OBJExporterFactory.java +++ b/core/src/net/sf/openrocket/file/wavefrontobj/export/OBJExporterFactory.java @@ -1,6 +1,8 @@ package net.sf.openrocket.file.wavefrontobj.export; import de.javagl.obj.ObjWriter; +import net.sf.openrocket.appearance.Appearance; +import net.sf.openrocket.appearance.defaults.DefaultAppearance; import net.sf.openrocket.file.wavefrontobj.CoordTransform; import net.sf.openrocket.file.wavefrontobj.DefaultMtl; import net.sf.openrocket.file.wavefrontobj.DefaultMtlWriter; @@ -16,6 +18,8 @@ import net.sf.openrocket.file.wavefrontobj.export.components.RocketComponentExpo import net.sf.openrocket.file.wavefrontobj.export.components.RingComponentExporter; import net.sf.openrocket.file.wavefrontobj.export.components.TransitionExporter; import net.sf.openrocket.file.wavefrontobj.export.components.TubeFinSetExporter; +import net.sf.openrocket.motor.Motor; +import net.sf.openrocket.motor.MotorConfiguration; import net.sf.openrocket.rocketcomponent.BodyTube; import net.sf.openrocket.rocketcomponent.ComponentAssembly; import net.sf.openrocket.rocketcomponent.FinSet; @@ -32,6 +36,7 @@ import net.sf.openrocket.rocketcomponent.Transition; import net.sf.openrocket.rocketcomponent.TubeFinSet; import net.sf.openrocket.util.FileUtils; +import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; @@ -61,7 +66,7 @@ public class OBJExporterFactory { private final List components; private final FlightConfiguration configuration; private final OBJExportOptions options; - private final String filePath; + private final File file; // The different exporters for each component private static final Map, ExporterFactory> EXPORTER_MAP = Map.of( @@ -81,13 +86,13 @@ public class OBJExporterFactory { * @param components List of components to export * @param configuration Flight configuration to use for the export * @param options Options to use for the export - * @param filePath Path to the file to export to + * @param file The file to export the OBJ to */ - public OBJExporterFactory(List components, FlightConfiguration configuration, String filePath, + public OBJExporterFactory(List components, FlightConfiguration configuration, File file, OBJExportOptions options) { this.components = components; this.configuration = configuration; - this.filePath = filePath; + this.file = file; this.options = options; } @@ -104,7 +109,7 @@ public class OBJExporterFactory { if (exportAsSeparateFiles) { objFileMap = new HashMap<>(); } else { - objFileMap = Map.of(this.filePath, obj); + objFileMap = Map.of(this.file.getAbsolutePath(), obj); } // Get all the components to export @@ -147,7 +152,7 @@ public class OBJExporterFactory { // If separate export, add this object to the map of objects to export if (exportAsSeparateFiles) { - String path = FileUtils.removeExtension(this.filePath) + "_" + groupName + ".obj"; + String path = FileUtils.removeExtension(this.file.getAbsolutePath()) + "_" + groupName + ".obj"; objFileMap.put(path, obj); } @@ -223,7 +228,15 @@ public class OBJExporterFactory { // Export material if (options.isExportAppearance()) { - AppearanceExporter appearanceExporter = new AppearanceExporter(obj, component, "mat_" + groupName, materials); + String materialName = "mat_" + groupName; + + // Get the component appearance + Appearance appearance = component.getAppearance(); + if (appearance == null) { + appearance = DefaultAppearance.getDefaultAppearance(component); + } + + AppearanceExporter appearanceExporter = new AppearanceExporter(obj, appearance, file, options, materialName, materials); appearanceExporter.doExport(); } @@ -233,6 +246,19 @@ public class OBJExporterFactory { // Export motor if (component instanceof MotorMount) { + // Get the motor + MotorConfiguration motoConfig = ((MotorMount) component).getMotorConfig(config.getId()); + Motor motor = motoConfig.getMotor(); + + // Export the motor appearance + if (options.isExportAppearance() && motor != null) { + String materialName = "mat_" + groupName + "_" + motor.getMotorName(); + Appearance appearance = DefaultAppearance.getDefaultAppearance(motor); + AppearanceExporter appearanceExporter = new AppearanceExporter(obj, appearance, file, options, materialName, materials); + appearanceExporter.doExport(); + } + + // Export the motor geometry MotorExporter motorExporter = new MotorExporter(obj, config, transformer, component, groupName, LOD); motorExporter.addToObj(); } diff --git a/core/src/net/sf/openrocket/file/wavefrontobj/export/components/MassObjectExporter.java b/core/src/net/sf/openrocket/file/wavefrontobj/export/components/MassObjectExporter.java index c1eb0acb9..4eb2e43f3 100644 --- a/core/src/net/sf/openrocket/file/wavefrontobj/export/components/MassObjectExporter.java +++ b/core/src/net/sf/openrocket/file/wavefrontobj/export/components/MassObjectExporter.java @@ -33,11 +33,12 @@ public class MassObjectExporter extends RocketComponentExporter { private void generateMesh(int numSides, int numStacks, InstanceContext context) { // Other meshes may have been added to the obj, so we need to keep track of the starting indices int startIdx = obj.getNumVertices(); + int texCoordsStartIdx = obj.getNumTexCoords(); int normalsStartIdx = obj.getNumNormals(); double dx = component.getLength() / numStacks; double da = 2.0f * Math.PI / numSides; - // Generate vertices and normals + // Generate vertices, normals and UVs for (int j = 0; j <= numStacks; j++) { double x = j * dx; @@ -45,6 +46,11 @@ public class MassObjectExporter extends RocketComponentExporter { // Add a center vertex obj.addVertex(transformer.convertLoc(x, 0, 0)); obj.addNormal(transformer.convertLocWithoutOriginOffs(j == 0 ? -1 : 1, 0, 0)); + for (int i = 0; i <= numSides; i++) { + final float u = (float) i / numSides; + final float v = j == 0 ? 1 : 0; + obj.addTexCoord(u, v); + } } else { // Add a vertex for each side for (int i = 0; i < numSides; i++) { @@ -53,9 +59,10 @@ public class MassObjectExporter extends RocketComponentExporter { double y = r * Math.cos(angle); double z = r * Math.sin(angle); + // Vertex obj.addVertex(transformer.convertLoc(x, y, z)); - // Add normals + // Normal if (Double.compare(r, component.getRadius()) == 0) { // If in cylindrical section, use cylinder normals obj.addNormal(transformer.convertLocWithoutOriginOffs(0, y, z)); } else { @@ -67,13 +74,23 @@ public class MassObjectExporter extends RocketComponentExporter { } obj.addNormal(transformer.convertLocWithoutOriginOffs(x - xCenter, y, z)); // For smooth shading } + + // Texture coordinate + final float u = (float) i / numSides; + final float v = (float) (numStacks-j) / numStacks; + obj.addTexCoord(u, v); } + + // Add final UV coordinate to close the texture + final float u = 1f; + final float v = (float) (numStacks-j) / numStacks; + obj.addTexCoord(u, v); } } int endIdx = Math.max(obj.getNumVertices() - 1, startIdx); // Clamp in case no vertices were added - // Create bottom tip faces + // Create top tip faces for (int i = 0; i < numSides; i++) { int nextIdx = (i + 1) % numSides; @@ -83,11 +100,17 @@ public class MassObjectExporter extends RocketComponentExporter { 1 + nextIdx, }; int[] normalIndices = vertexIndices.clone(); // For a smooth surface, the vertex and normal indices are the same + int[] texCoordIndices = new int[] { + i, + numSides+1 + i, + numSides+1 + i+1, + }; ObjUtils.offsetIndex(normalIndices, normalsStartIdx); + ObjUtils.offsetIndex(texCoordIndices, texCoordsStartIdx); ObjUtils.offsetIndex(vertexIndices, startIdx); // Only do this after normals are added, since the vertex indices are used for normals - DefaultObjFace face = new DefaultObjFace(vertexIndices, null, normalIndices); + DefaultObjFace face = new DefaultObjFace(vertexIndices, texCoordIndices, normalIndices); obj.addFace(face); } @@ -103,16 +126,23 @@ public class MassObjectExporter extends RocketComponentExporter { 1 + j * numSides + nextIdx }; int[] normalIndices = vertexIndices.clone(); // For a smooth surface, the vertex and normal indices are the same + int[] texCoordIndices = new int[] { + j + j * numSides + i, + j+1 + (j + 1) * numSides + i, + j+1 + (j + 1) * numSides + i+1, + j + j * numSides + i+1 + }; ObjUtils.offsetIndex(normalIndices, normalsStartIdx); + ObjUtils.offsetIndex(texCoordIndices, texCoordsStartIdx + numSides+1); ObjUtils.offsetIndex(vertexIndices, startIdx); // Only do this after normals are added, since the vertex indices are used for normals - DefaultObjFace face = new DefaultObjFace(vertexIndices, null, normalIndices); + DefaultObjFace face = new DefaultObjFace(vertexIndices, texCoordIndices, normalIndices); obj.addFace(face); } } - // Create top tip faces + // Create bottom tip faces final int normalEndIdx = obj.getNumNormals() - 1; for (int i = 0; i < numSides; i++) { int nextIdx = (i + 1) % numSides; @@ -126,10 +156,16 @@ public class MassObjectExporter extends RocketComponentExporter { normalEndIdx - numSides + nextIdx, normalEndIdx - numSides + i, }; + int[] texCoordIndices = new int[] { + numSides+1 + i, + i+1, + i, + }; - // Don't offset! We reference from the last index + ObjUtils.offsetIndex(texCoordIndices, texCoordsStartIdx + ((numStacks-1) * (numSides + 1)) ); + // Don't offset vertices or normals! We reference from the last index - DefaultObjFace face = new DefaultObjFace(vertexIndices, null, normalIndices); + DefaultObjFace face = new DefaultObjFace(vertexIndices, texCoordIndices, normalIndices); obj.addFace(face); } diff --git a/core/src/net/sf/openrocket/file/wavefrontobj/export/components/MotorExporter.java b/core/src/net/sf/openrocket/file/wavefrontobj/export/components/MotorExporter.java index 1d59d9744..e742d3152 100644 --- a/core/src/net/sf/openrocket/file/wavefrontobj/export/components/MotorExporter.java +++ b/core/src/net/sf/openrocket/file/wavefrontobj/export/components/MotorExporter.java @@ -56,7 +56,7 @@ public class MotorExporter { return; } - obj.setActiveGroupNames(motor.getMotorName()); + obj.setActiveGroupNames(groupName + "_" + motor.getMotorName()); for (InstanceContext context : config.getActiveInstances().getInstanceContexts(mount)) { generateMesh(motor, context); diff --git a/core/src/net/sf/openrocket/file/wavefrontobj/export/components/TransitionExporter.java b/core/src/net/sf/openrocket/file/wavefrontobj/export/components/TransitionExporter.java index 6c3a2a46e..e9a100c4e 100644 --- a/core/src/net/sf/openrocket/file/wavefrontobj/export/components/TransitionExporter.java +++ b/core/src/net/sf/openrocket/file/wavefrontobj/export/components/TransitionExporter.java @@ -119,6 +119,7 @@ public class TransitionExporter extends RocketComponentExporter { // Other meshes may have been added to the obj, so we need to keep track of the starting indices final int startIdx = obj.getNumVertices(); final int normalsStartIdx = obj.getNumNormals(); + final int texCoordsStartIdx = obj.getNumTexCoords(); final double dxBase = component.getLength() / numStacks; // Base step size in the longitudinal direction final double actualLength = estimateActualLength(offsetRadius, dxBase); // Actual length of the transition (due to reduced step size near the fore/aft end) @@ -176,6 +177,12 @@ public class TransitionExporter extends RocketComponentExporter { float xTip = getTipLocation(x, xNext, offsetRadius, epsilon); obj.addVertex(transformer.convertLoc(xTip, 0, 0)); obj.addNormal(transformer.convertLocWithoutOriginOffs(isOutside ? -1 : 1, 0, 0)); + for (int i = 0; i <= numSides; i++) { + float u = (float) i / numSides; + float v = 1f; + obj.addTexCoord(u, v); + } + isForeTip = true; // The fore tip is the first fore "ring" @@ -218,6 +225,11 @@ public class TransitionExporter extends RocketComponentExporter { float xTip = getTipLocation(x, xNext, offsetRadius, epsilon); obj.addVertex(transformer.convertLoc(xTip, 0, 0)); obj.addNormal(transformer.convertLocWithoutOriginOffs(isOutside ? 1 : -1, 0, 0)); + for (int i = 0; i <= numSides; i++) { + float u = (float) i / numSides; + float v = 0f; + obj.addTexCoord(u, v); + } isAftTip = true; // The aft tip is the aft "ring" @@ -251,17 +263,20 @@ public class TransitionExporter extends RocketComponentExporter { // Create regular faces int corrVStartIdx = isForeTip ? startIdx + 1 : startIdx; int corrNStartIdx = isForeTip ? normalsStartIdx + 1 : normalsStartIdx; - addQuadFaces(numSlices, actualNumStacks, corrVStartIdx, corrNStartIdx, isOutside); + int corrTStartIdx = isForeTip ? texCoordsStartIdx + numSides+1 : texCoordsStartIdx; + addQuadFaces(numSides, actualNumStacks, corrVStartIdx, corrNStartIdx, corrTStartIdx, isOutside); } - private void addTipFaces(int numSlices, boolean isOutside, boolean isForeTip, int startIdx, int normalsStartIdx) { + private void addTipFaces(int numSides, boolean isOutside, boolean isForeTip, int startIdx, int normalsStartIdx, int texCoordsStartIdx) { final int lastIdx = obj.getNumVertices() - 1; - for (int i = 0; i < numSlices; i++) { - int nextIdx = (i + 1) % numSlices; + for (int i = 0; i < numSides; i++) { + int nextIdx = (i + 1) % numSides; int[] vertexIndices; int[] normalIndices; + int[] texCoordsIndices; // Fore tip if (isForeTip) { + // Vertices vertexIndices = new int[] { 0, // Fore tip vertex 1 + i, @@ -269,46 +284,69 @@ public class TransitionExporter extends RocketComponentExporter { }; vertexIndices = ObjUtils.reverseIndexWinding(vertexIndices, !isOutside); + // Normals normalIndices = vertexIndices.clone(); // No need to reverse, already done by vertices + // Texture coordinates + texCoordsIndices = new int[] { + i, + numSides+1 + i, + numSides+1 + i+1, + }; + texCoordsIndices = ObjUtils.reverseIndexWinding(texCoordsIndices, !isOutside); + ObjUtils.offsetIndex(normalIndices, normalsStartIdx); + ObjUtils.offsetIndex(texCoordsIndices, texCoordsStartIdx); ObjUtils.offsetIndex(vertexIndices, startIdx); // Do this last, otherwise the normal indices will be wrong } // Aft tip else { + // Vertices vertexIndices = new int[] { lastIdx, // Aft tip vertex - lastIdx - numSlices + nextIdx, - lastIdx - numSlices + i, + lastIdx - numSides + nextIdx, + lastIdx - numSides + i, }; vertexIndices = ObjUtils.reverseIndexWinding(vertexIndices, !isOutside); + // Normals int lastNormalIdx = obj.getNumNormals() - 1; normalIndices = new int[] { lastNormalIdx, // Aft tip vertex - lastNormalIdx - numSlices + nextIdx, - lastNormalIdx - numSlices + i, + lastNormalIdx - numSides + nextIdx, + lastNormalIdx - numSides + i, }; normalIndices = ObjUtils.reverseIndexWinding(normalIndices, !isOutside); + // Texture coordinates + int lastTexCoordsIdx = obj.getNumTexCoords() - 1; + texCoordsIndices = new int[] { + lastTexCoordsIdx - (numSides+1) + i, // Aft tip vertex + lastTexCoordsIdx - 2*(numSides+1) + i+1, + lastTexCoordsIdx - 2*(numSides+1) + i, + }; + // No need to offset the indices, because we reference the last vertex (this caused a lot of debugging frustration hmfmwl) } - DefaultObjFace face = new DefaultObjFace(vertexIndices, null, normalIndices); + DefaultObjFace face = new DefaultObjFace(vertexIndices, texCoordsIndices, normalIndices); obj.addFace(face); } } - private void addQuadVertices(int numSlices, List foreRingVertices, List aftRingVertices, + private void addQuadVertices(int numSides, List foreRingVertices, List aftRingVertices, double r, double rNext, float x, float xNext, boolean isForeRing, boolean isAftRing, boolean isOutside) { - for (int i = 0; i < numSlices; i++) { - double angle = 2 * Math.PI * i / numSlices; + final double length = component.getLength(); + + for (int i = 0; i < numSides; i++) { + double angle = 2 * Math.PI * i / numSides; float y = (float) (r * Math.cos(angle)); float z = (float) (r * Math.sin(angle)); + // Vertex obj.addVertex(transformer.convertLoc(x, y, z)); - // Add the ring vertices to the lists + //// Add the ring vertices to the lists if (isForeRing) { foreRingVertices.add(obj.getNumVertices()-1); } @@ -316,8 +354,8 @@ public class TransitionExporter extends RocketComponentExporter { aftRingVertices.add(obj.getNumVertices()-1); } - // Calculate the normal - // We need special nx normal when the radius changes + // Normal + //// We need special nx normal when the radius changes float nx; if (Double.compare(r, rNext) != 0) { final double slopeAngle = Math.atan(Math.abs(xNext - x) / (rNext - r)); @@ -332,27 +370,49 @@ public class TransitionExporter extends RocketComponentExporter { ny = isOutside ? ny : -ny; nz = isOutside ? nz : -nz; obj.addNormal(transformer.convertLocWithoutOriginOffs(nx, ny, nz)); + + // Texture coordinates + float u = (float) i / numSides; + float v = (float) ((length-x) / length); + obj.addTexCoord(u, v); } + + // Need to add a last texture coordinate to close the texture + final float v = (float) ((length-x) / length); + obj.addTexCoord(1f, v); } - private void addQuadFaces(int numSlices, int numStacks, int startIdx, int normalsStartIdx, boolean isOutside) { + private void addQuadFaces(int numSides, int numStacks, int startIdx, int normalsStartIdx, int texCoordsStartIdx, boolean isOutside) { for (int i = 0; i < numStacks - 1; i++) { - for (int j = 0; j < numSlices; j++) { - final int nextIdx = (j + 1) % numSlices; + for (int j = 0; j < numSides; j++) { + final int nextIdx = (j + 1) % numSides; + + // Vertices int[] vertexIndices = new int[] { - i * numSlices + j, // Bottom-left of quad - (i + 1) * numSlices + j, // Top-left of quad - (i + 1) * numSlices + nextIdx, // Top-right of quad - i * numSlices + nextIdx, // Bottom-right of quad + i * numSides + j, // Bottom-left of quad + (i + 1) * numSides + j, // Top-left of quad + (i + 1) * numSides + nextIdx, // Top-right of quad + i * numSides + nextIdx, // Bottom-right of quad }; vertexIndices = ObjUtils.reverseIndexWinding(vertexIndices, !isOutside); + // Normals int[] normalIndices = vertexIndices.clone(); // No reversing needed, already done by vertices + // Texture coordinates + int[] texCoordsIndices = new int[] { + i * (numSides+1) + j, + (i + 1) * (numSides+1) + j, + (i + 1) * (numSides+1) + j+1, + i * (numSides+1) + j+1, + }; + texCoordsIndices = ObjUtils.reverseIndexWinding(texCoordsIndices, !isOutside); + ObjUtils.offsetIndex(normalIndices, normalsStartIdx); + ObjUtils.offsetIndex(texCoordsIndices, texCoordsStartIdx); ObjUtils.offsetIndex(vertexIndices, startIdx); // Do this last, otherwise the normal indices will be wrong - DefaultObjFace face = new DefaultObjFace(vertexIndices, null, normalIndices); + DefaultObjFace face = new DefaultObjFace(vertexIndices, texCoordsIndices, normalIndices); obj.addFace(face); } } diff --git a/core/src/net/sf/openrocket/file/wavefrontobj/export/shapes/CylinderExporter.java b/core/src/net/sf/openrocket/file/wavefrontobj/export/shapes/CylinderExporter.java index 2e6926cfe..03ed31936 100644 --- a/core/src/net/sf/openrocket/file/wavefrontobj/export/shapes/CylinderExporter.java +++ b/core/src/net/sf/openrocket/file/wavefrontobj/export/shapes/CylinderExporter.java @@ -38,6 +38,7 @@ public class CylinderExporter { // Other meshes may have been added to the obj, so we need to keep track of the starting indices int startIdx = obj.getNumVertices(); + int texCoordsStartIdx = obj.getNumTexCoords(); int normalsStartIdx = obj.getNumNormals(); if (solid) { @@ -51,16 +52,18 @@ public class CylinderExporter { } // Generate side top vertices - generateRingVertices(obj, transformer, numSides, 0, length, foreRadius, aftRadius, isOutside, foreRingVertices, foreRingNormals); + generateRingVertices(obj, transformer, numSides, 0, length, length, foreRadius, aftRadius, isOutside, foreRingVertices, foreRingNormals); // Generate side bottom vertices - generateRingVertices(obj, transformer, numSides, length, 0, aftRadius, foreRadius, isOutside, aftRingVertices, aftRingNormals); + generateRingVertices(obj, transformer, numSides, length, 0, length, aftRadius, foreRadius, isOutside, aftRingVertices, aftRingNormals); // Create faces for the bottom and top if (solid) { for (int i = 0; i < numSides; i++) { int nextIdx = (i + 1) % numSides; + // Bottom face + //// Vertices int[] vertexIndices = new int[] { 0, // Bottom center vertex 2 + i, @@ -69,13 +72,18 @@ public class CylinderExporter { vertexIndices = ObjUtils.reverseIndexWinding(vertexIndices, !isOutside); ObjUtils.offsetIndex(vertexIndices, startIdx); + //// Normals int[] normalIndices = new int[]{0, 0, 0}; ObjUtils.offsetIndex(normalIndices, normalsStartIdx); + //// Texture coordinates + // TODO? + DefaultObjFace face = new DefaultObjFace(vertexIndices, null, normalIndices); obj.addFace(face); // Top face + //// Vertices vertexIndices = new int[] { 1, // Top center vertex 2 + numSides + ((i + 1) % numSides), @@ -84,9 +92,13 @@ public class CylinderExporter { vertexIndices = ObjUtils.reverseIndexWinding(vertexIndices, !isOutside); ObjUtils.offsetIndex(vertexIndices, startIdx); + //// Normals normalIndices = new int[] {1, 1, 1}; ObjUtils.offsetIndex(normalIndices, normalsStartIdx); + //// Texture coordinates + // TODO + face = new DefaultObjFace(vertexIndices, null, normalIndices); obj.addFace(face); } @@ -97,6 +109,7 @@ public class CylinderExporter { final int nextIdx = (i + 1) % numSides; final int offset = solid ? 2 : 0; // Offset by 2 to skip the bottom and top center vertices + //// Vertices int[] vertexIndices = new int[]{ i, // Bottom-left of quad numSides + i, // Top-left of quad @@ -105,12 +118,23 @@ public class CylinderExporter { }; vertexIndices = ObjUtils.reverseIndexWinding(vertexIndices, !isOutside); + //// Normals int[] normalIndices = vertexIndices.clone(); // No need to reverse winding, already done by vertices + //// Texture coordinates + int[] texCoordIndices = new int[]{ + i, // Bottom-left of quad + numSides+1 + i, // Top-left of quad + numSides+1 + i+1, // Top-right of quad (don't use nextIdx, we don't want roll-over) + i+1, // Bottom-right of quad (don't use nextIdx, we don't want roll-over) + }; + texCoordIndices = ObjUtils.reverseIndexWinding(texCoordIndices, !isOutside); + ObjUtils.offsetIndex(normalIndices, normalsStartIdx + offset); + ObjUtils.offsetIndex(texCoordIndices, texCoordsStartIdx); ObjUtils.offsetIndex(vertexIndices, startIdx + offset); // ! Only add offset here, otherwise you mess up the indices for the normals - DefaultObjFace face = new DefaultObjFace(vertexIndices, null, normalIndices); + DefaultObjFace face = new DefaultObjFace(vertexIndices, texCoordIndices, normalIndices); obj.addFace(face); } } @@ -147,7 +171,7 @@ public class CylinderExporter { } public static void generateRingVertices(DefaultObj obj, CoordTransform transformer, - int numSides, float x, float nextX, float radius, float nextRadius, + int numSides, float x, float nextX, float xMax, float radius, float nextRadius, boolean isOutside, List vertexList, List normalList) { int startIdx = obj.getNumVertices(); int normalsStartIdx = obj.getNumNormals(); @@ -183,6 +207,15 @@ public class CylinderExporter { if (normalList != null) { normalList.add(normalsStartIdx + i); } + + // Texture coordinates + final float u = (float) i / numSides; + final float v = isOutside ? (xMax - x) / xMax : x / xMax; // For some reason, the texture is vertically flipped in OR for inside cylinders. Don't really like it, but it is what it is + obj.addTexCoord(u, v); } + + // Need to add a last texture coordinate for the end of the texture + final float v = isOutside ? (xMax - x) / xMax : x / xMax; + obj.addTexCoord(1f, v); } } 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 a25110105..e380b3408 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 @@ -32,39 +32,60 @@ public class PolygonExporter { // NOTE: "front" is the side view with the normal pointing towards the viewer, "back" is the side with the normal pointing away from the viewer + // Calculate the boundaries of the polygon + Boundaries boundaries = new Boundaries(pointLocationsX, pointLocationsY); + obj.addNormal(transformer.convertLocWithoutOriginOffs(0, 0, -1)); // Front faces normal obj.addNormal(transformer.convertLocWithoutOriginOffs(0, 0, 1)); // Back faces normal // Generate front face vertices for (int i = 0; i < pointLocationsX.length; i++) { obj.addVertex(transformer.convertLoc(pointLocationsX[i], pointLocationsY[i], -thickness/2)); + + // Compute texture coordinates based on normalized position + float u = (pointLocationsX[i] - boundaries.getMinX()) / (boundaries.getMaxX() - boundaries.getMinX()); + u = 1f - u; + float v = (pointLocationsY[i] - boundaries.getMinY()) / (boundaries.getMaxY() - boundaries.getMinY()); + v = 1f - v; + obj.addTexCoord(u, v); } // Generate back face vertices for (int i = 0; i < pointLocationsX.length; i++) { obj.addVertex(transformer.convertLoc(pointLocationsX[i], pointLocationsY[i], thickness/2)); + + // Compute texture coordinates based on normalized position + float u = (pointLocationsX[i] - boundaries.getMinX()) / (boundaries.getMaxX() - boundaries.getMinX()); + u = 1f - u; + float v = (pointLocationsY[i] - boundaries.getMinY()) / (boundaries.getMaxY() - boundaries.getMinY()); + v = 1f - v; + obj.addTexCoord(u, v); } // Create front face int[] vertexIndices = new int[pointLocationsX.length]; int[] normalIndices = new int[pointLocationsX.length]; + int[] texCoordsIndices = new int[pointLocationsX.length]; for (int i = 0; i < pointLocationsX.length; i++) { vertexIndices[i] = pointLocationsX.length-1 - i; normalIndices[i] = normalsStartIdx; + texCoordsIndices[i] = pointLocationsX.length-1 - i; } ObjUtils.offsetIndex(vertexIndices, startIdx); - DefaultObjFace face = new DefaultObjFace(vertexIndices, null, normalIndices); + DefaultObjFace face = new DefaultObjFace(vertexIndices, texCoordsIndices, normalIndices); obj.addFace(face); // Create back face vertexIndices = new int[pointLocationsX.length]; normalIndices = new int[pointLocationsX.length]; + texCoordsIndices = new int[pointLocationsX.length]; for (int i = 0; i < pointLocationsX.length; i++) { vertexIndices[i] = pointLocationsX.length + i; normalIndices[i] = normalsStartIdx + 1; + texCoordsIndices[i] = pointLocationsX.length + i; } ObjUtils.offsetIndex(vertexIndices, startIdx); - face = new DefaultObjFace(vertexIndices, null, normalIndices); + face = new DefaultObjFace(vertexIndices, texCoordsIndices, normalIndices); obj.addFace(face); // Create side faces @@ -111,4 +132,55 @@ public class PolygonExporter { throw new IllegalArgumentException("The first and last points must be different"); } } + + /** + * Calculate the boundaries of a polygon. + */ + private static class Boundaries { + private float minX; + private float maxX; + private float minY; + private float maxY; + + public Boundaries(float[] pointsX, float[] pointsY) { + this.minX = Float.MAX_VALUE; + this.maxX = Float.MIN_VALUE; + this.minY = Float.MAX_VALUE; + this.maxY = Float.MIN_VALUE; + + for (int i = 0; i < pointsX.length; i++) { + float x = pointsX[i]; + float y = pointsY[i]; + + if (x < minX) { + minX = x; + } + if (x > maxX) { + maxX = x; + } + if (y < minY) { + minY = y; + } + if (y > maxY) { + maxY = y; + } + } + } + + public float getMinX() { + return minX; + } + + public float getMaxX() { + return maxX; + } + + public float getMinY() { + return minY; + } + + public float getMaxY() { + return maxY; + } + } } diff --git a/core/src/net/sf/openrocket/util/FileUtils.java b/core/src/net/sf/openrocket/util/FileUtils.java index 5cfeb3321..09b77bfdc 100644 --- a/core/src/net/sf/openrocket/util/FileUtils.java +++ b/core/src/net/sf/openrocket/util/FileUtils.java @@ -6,6 +6,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.nio.file.Paths; public abstract class FileUtils { @@ -51,4 +52,13 @@ public abstract class FileUtils { return fileName; } + /** + * Get the file name from a path. + * @param pathString the path (e.g. "my/file/path.txt") + * @return the file name (e.g. "path.txt") + */ + public static String getFileNameFromPath(String pathString) { + return Paths.get(pathString).getFileName().toString(); + } + } 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 8a087066c..2c550027c 100644 --- a/core/test/net/sf/openrocket/file/wavefrontobj/export/OBJExporterFactoryTest.java +++ b/core/test/net/sf/openrocket/file/wavefrontobj/export/OBJExporterFactoryTest.java @@ -15,6 +15,7 @@ import net.sf.openrocket.file.RocketLoadException; import net.sf.openrocket.file.openrocket.OpenRocketSaverTest; import net.sf.openrocket.file.wavefrontobj.CoordTransform; import net.sf.openrocket.file.wavefrontobj.DefaultCoordTransform; +import net.sf.openrocket.file.wavefrontobj.ObjUtils; import net.sf.openrocket.l10n.DebugTranslator; import net.sf.openrocket.l10n.Translator; import net.sf.openrocket.plugin.PluginModule; @@ -158,29 +159,29 @@ public class OBJExporterFactoryTest { // Create a temp file for storing the exported OBJ Path tempFile = Files.createTempFile("testExport", ".obj"); - String filePath = tempFile.toAbsolutePath().toString(); - - - filePath = "/Users/SiboVanGool/Downloads/testExport.obj"; // TODO: remove this line - // Do the exporting OBJExportOptions options = new OBJExportOptions(rocket); options.setScaling(30); options.setExportChildren(true); - OBJExporterFactory exporterFactory = new OBJExporterFactory(components, rocket.getSelectedConfiguration(), filePath, options); + options.setExportAppearance(true); + options.setRemoveOffset(true); + OBJExporterFactory exporterFactory = new OBJExporterFactory(components, rocket.getSelectedConfiguration(), tempFile.toFile(), options); exporterFactory.doExport(); // Test with other parameters - /*noseCone.setShoulderCapped(false); + noseCone.setShoulderCapped(false); railButton.setScrewHeight(0); bodyTube.setFilled(true); - transformer = new DefaultCoordTransform(rocket.getLength()); - exporterFactory = new OBJExporterFactory(components, rocket.getSelectedConfiguration(), true, false, false, - transformer, filePath); - exporterFactory.doExport();*/ + options.setTriangulate(true); + options.setRemoveOffset(false); + options.setScaling(1000); + options.setLOD(ObjUtils.LevelOfDetail.LOW_QUALITY); + + exporterFactory = new OBJExporterFactory(components, rocket.getSelectedConfiguration(), tempFile.toFile(), options); + exporterFactory.doExport(); // Clean up Files.delete(tempFile); diff --git a/swing/src/net/sf/openrocket/file/wavefrontobj/OBJOptionChooser.java b/swing/src/net/sf/openrocket/file/wavefrontobj/OBJOptionChooser.java index 048af06e1..0b1c4c872 100644 --- a/swing/src/net/sf/openrocket/file/wavefrontobj/OBJOptionChooser.java +++ b/swing/src/net/sf/openrocket/file/wavefrontobj/OBJOptionChooser.java @@ -26,6 +26,7 @@ public class OBJOptionChooser extends JPanel { private final JCheckBox exportAsSeparateFiles; private final JCheckBox removeOffset; private final JCheckBox triangulate; + private final JCheckBox sRGB; private final JComboBox LOD; private final List selectedComponents; @@ -73,6 +74,10 @@ public class OBJOptionChooser extends JPanel { this.triangulate.setToolTipText(trans.get("OBJOptionChooser.checkbox.triangulate.ttip")); advancedOptionsPanel.add(triangulate, "spanx, wrap"); + //// Export colors in sRGB + this.sRGB = new JCheckBox(trans.get("OBJOptionChooser.checkbox.sRGB")); + this.sRGB.setToolTipText(trans.get("OBJOptionChooser.checkbox.sRGB.ttip")); + advancedOptionsPanel.add(sRGB, "spanx, wrap"); //// Level of detail JLabel LODLabel = new JLabel(trans.get("OBJOptionChooser.lbl.LevelOfDetail")); @@ -124,6 +129,7 @@ public class OBJOptionChooser extends JPanel { this.exportAsSeparateFiles.setSelected(opts.isExportAsSeparateFiles()); this.removeOffset.setSelected(opts.isRemoveOffset()); this.triangulate.setSelected(opts.isTriangulate()); + this.sRGB.setSelected(opts.isUseSRGB()); this.LOD.setSelectedItem(opts.getLOD()); } @@ -144,6 +150,7 @@ public class OBJOptionChooser extends JPanel { opts.setExportAsSeparateFiles(exportAsSeparateFiles.isSelected()); opts.setRemoveOffset(removeOffset.isSelected()); opts.setTriangulate(triangulate.isSelected()); + opts.setUseSRGB(sRGB.isSelected()); opts.setLOD((ObjUtils.LevelOfDetail) LOD.getSelectedItem()); } diff --git a/swing/src/net/sf/openrocket/gui/main/BasicFrame.java b/swing/src/net/sf/openrocket/gui/main/BasicFrame.java index 508a9a3c5..08870e1ab 100644 --- a/swing/src/net/sf/openrocket/gui/main/BasicFrame.java +++ b/swing/src/net/sf/openrocket/gui/main/BasicFrame.java @@ -1678,7 +1678,7 @@ public class BasicFrame extends JFrame { */ private boolean saveWavefrontOBJFile(File file, OBJExportOptions options) { OBJExporterFactory exporter = new OBJExporterFactory(getSelectedComponents(), rocket.getSelectedConfiguration(), - file.getAbsolutePath(), options); + file, options); exporter.doExport(); return true;