diff --git a/core/resources/l10n/messages.properties b/core/resources/l10n/messages.properties
index 5d605f0ce..163318edd 100644
--- a/core/resources/l10n/messages.properties
+++ b/core/resources/l10n/messages.properties
@@ -127,6 +127,7 @@ FileHelper.RASAERO_DESIGN_FILTER = RASAero designs (*.CDX1)
FileHelper.WAVEFRONT_OBJ_FILTER = Wavefront OBJ 3D file (*.obj)
FileHelper.OPEN_ROCKET_COMPONENT_FILTER = OpenRocket presets (*.orc)
FileHelper.PNG_FILTER = PNG image (*.png)
+FileHelper.SVG_FILTER = SVG files (*.svg)
FileHelper.IMAGES = Image files
FileHelper.XML_FILTER = XML files (*.xml)
@@ -708,6 +709,12 @@ SimExpPan.Col.Unit = Unit
CsvOptionPanel.separator.space = SPACE
CsvOptionPanel.separator.tab = TAB
+!SVGOptionPanel
+SVGOptionPanel.lbl.strokeColor = Stroke color
+SVGOptionPanel.lbl.strokeColor.ttip = The color of the lines in the SVG image.
+SVGOptionPanel.lbl.strokeWidth = Stroke width (mm)
+SVGOptionPanel.lbl.strokeWidth.ttip = The width of the lines in the SVG image in millimeters.
+
! Custom expression general stuff
customExpression.Name = Name
@@ -959,6 +966,8 @@ FinSetConfig.but.Converttofreeform.ttip = Convert this fin set into a freeform f
FinSetConfig.Convertfinset = Convert fin set
FinSetConfig.but.Splitfins = Split fins
FinSetConfig.but.Splitfins.ttip = Split the fin set into separate fins.
+FinSetConfig.lbl.exportSVG = Export to SVG
+FinSetConfig.lbl.exportSVG.ttip = Export the fin profile to an SVG file.
FinSetConfig.but.AutoCalc = Calculate automatically
FinSetConfig.but.AutoCalc.ttip = Calculates the height and length of the fin tabs, based on
inner tube and centering ring components of the fin's parent component.
FinSetConfig.lbl.Through-the-wall = Through-the-wall fin tabs:
diff --git a/core/src/net/sf/openrocket/file/svg/export/SVGBuilder.java b/core/src/net/sf/openrocket/file/svg/export/SVGBuilder.java
new file mode 100644
index 000000000..40726f58f
--- /dev/null
+++ b/core/src/net/sf/openrocket/file/svg/export/SVGBuilder.java
@@ -0,0 +1,177 @@
+package net.sf.openrocket.file.svg.export;
+
+import net.sf.openrocket.util.Coordinate;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.transform.OutputKeys;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerException;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+import java.awt.Color;
+import java.io.File;
+import java.util.Locale;
+
+/**
+ * SVGBuilder is a class that allows you to build SVG (Scalable Vector Graphics) files.
+ * The functionality is limited to the bare minimum needed to export shapes from OpenRocket.
+ *
+ * @author Sibo Van Gool
+ */
+public class SVGBuilder {
+ private final Document doc;
+ private final Element svgRoot;
+
+ private double minX = Double.MAX_VALUE;
+ private double minY = Double.MAX_VALUE;
+ private double maxX = Double.MIN_VALUE;
+ private double maxY = Double.MIN_VALUE;
+
+ /**
+ * Different stroke cap styles.
+ */
+ public enum LineCap {
+ BUTT("butt"), // Stroke does not extend beyond the end of the line
+ ROUND("round"), // Stroke extends beyond the end of the line by a half circle
+ SQUARE("square"); // Stroke extends beyond the end of the line by half the stroke width
+
+ private final String value;
+
+ LineCap(String value) {
+ this.value = value;
+ }
+
+ public String getValue() {
+ return value;
+ }
+ }
+
+ private static final double OR_UNIT_TO_SVG_UNIT = 1000; // OpenRocket units are in meters, SVG units are in mm
+
+ /**
+ * Creates a new SVGBuilder instance.
+ *
+ * @throws ParserConfigurationException if a DocumentBuilder cannot be created
+ */
+ public SVGBuilder() throws ParserConfigurationException {
+ DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance();
+ DocumentBuilder docBuilder = docFactory.newDocumentBuilder();
+
+ // Root element
+ this.doc = docBuilder.newDocument();
+ this.svgRoot = this.doc.createElement("svg");
+ this.svgRoot.setAttribute("xmlns", "http://www.w3.org/2000/svg");
+ this.svgRoot.setAttribute("version", "1.1");
+ this.doc.appendChild(this.svgRoot);
+ }
+
+ /**
+ * Adds a path to the SVG document.
+ * The path is defined by a list of coordinates, where each coordinate represents a point on the path.
+ *
+ * @param coordinates the array of coordinates defining the path (coordinates are in meters)
+ * @param xPos the offset x-axis position of the path (coordinates are in meters)
+ * @param yPos the offset y-axis position of the path (coordinates are in meters)
+ * @param fill the color used to fill the path, or null if the path should not be filled
+ * @param stroke the color used to stroke the path, or null if the path should not be stroked
+ * @param strokeWidth the width of the path stroke (in millimeters)
+ * @param lineCap the line cap style of the path
+ */
+ public void addPath(Coordinate[] coordinates, double xPos, double yPos, Color fill, Color stroke, double strokeWidth,
+ LineCap lineCap) {
+ final Element path = this.doc.createElement("path");
+ final StringBuilder dAttribute = new StringBuilder();
+
+ for (int i = 0; i < coordinates.length; i++) {
+ final Coordinate coord = coordinates[i];
+ double x = (coord.x + xPos) * OR_UNIT_TO_SVG_UNIT;
+ double y = (coord.y+ yPos) * OR_UNIT_TO_SVG_UNIT;
+ updateCanvasSize(x, y);
+ final String command = (i == 0) ? "M" : "L";
+ dAttribute.append(String.format(Locale.ENGLISH, "%s%.1f,%.1f ", command, x, y)); // Coordinates are in meters, SVG is in mm
+ }
+
+ path.setAttribute("d", dAttribute.toString());
+ path.setAttribute("fill", colorToString(fill));
+ path.setAttribute("stroke", colorToString(stroke));
+ path.setAttribute("stroke-width", String.format(Locale.ENGLISH, "%.001f", strokeWidth));
+ path.setAttribute("stroke-linecap", lineCap.getValue());
+ svgRoot.appendChild(path);
+ }
+
+ public void addPath(Coordinate[] coordinates, Color fill, Color stroke, double strokeWidth, LineCap lineCap) {
+ addPath(coordinates, 0, 0, fill, stroke, strokeWidth, lineCap);
+ }
+
+ public void addPath(Coordinate[] coordinates, Color fill, Color stroke, double strokeWidth) {
+ addPath(coordinates, fill, stroke, strokeWidth, LineCap.SQUARE);
+ }
+
+ /**
+ * Updates the canvas size based on the given coordinates.
+ *
+ * @param x the x-coordinate
+ * @param y the y-coordinate
+ */
+ private void updateCanvasSize(double x, double y) {
+ if (x < minX) minX = x;
+ if (y < minY) minY = y;
+ if (x > maxX) maxX = x;
+ if (y > maxY) maxY = y;
+ }
+
+ /**
+ * Finalizes the SVG document by setting the width, height and viewBox attributes.
+ */
+ public void finalizeSVG() {
+ svgRoot.setAttribute("width", (maxX - minX) + "mm");
+ svgRoot.setAttribute("height", (maxY - minY) + "mm");
+ svgRoot.setAttribute("viewBox", minX + " " + minY + " " + (maxX - minX) + " " + (maxY - minY));
+ }
+
+ /**
+ * Converts a color to an SVG string representation.
+ *
+ * @param color the color to convert
+ * @return the string representation of the color
+ */
+ private String colorToString(Color color) {
+ return color == null ?
+ "none" :
+ String.format("rgb(%d,%d,%d)", color.getRed(), color.getGreen(), color.getBlue());
+ }
+
+ /**
+ * Writes the SVG document to a file.
+ * @param file the file to write to
+ * @throws TransformerException if an error occurs while writing the file
+ */
+ public void writeToFile(File file) throws TransformerException {
+ finalizeSVG();
+ TransformerFactory transformerFactory = TransformerFactory.newInstance();
+ Transformer transformer = transformerFactory.newTransformer();
+ transformer.setOutputProperty(OutputKeys.INDENT, "yes");
+ DOMSource source = new DOMSource(doc);
+ StreamResult result = new StreamResult(file);
+ transformer.transform(source, result);
+ }
+
+ public static void main(String[] args) throws ParserConfigurationException, TransformerException {
+ SVGBuilder svgBuilder = new SVGBuilder();
+
+ Coordinate[] coordinates = {
+ new Coordinate(0, 0),
+ new Coordinate(0, 0.01),
+ new Coordinate(0.02, 0.02),
+ new Coordinate(0.01, 0),
+ new Coordinate(0, 0)};
+
+ svgBuilder.addPath(coordinates, null, Color.BLACK, 0.1);
+ svgBuilder.writeToFile(new File("/Users/SiboVanGool/Downloads/shape.svg"));
+ }
+}
diff --git a/core/src/net/sf/openrocket/startup/Preferences.java b/core/src/net/sf/openrocket/startup/Preferences.java
index c244276cc..17d70440e 100644
--- a/core/src/net/sf/openrocket/startup/Preferences.java
+++ b/core/src/net/sf/openrocket/startup/Preferences.java
@@ -1,5 +1,6 @@
package net.sf.openrocket.startup;
+import java.awt.Color;
import java.util.ArrayList;
import java.util.EventListener;
import java.util.EventObject;
@@ -138,6 +139,10 @@ public abstract class Preferences implements ChangeSource {
private static final String OBJ_ORIG_X_OFFS = "OrigXOffs";
private static final String OBJ_ORIG_Y_OFFS = "OrigYOffs";
private static final String OBJ_ORIG_Z_OFFS = "OrigZOffs";
+
+ // SVG export options
+ public static final String SVG_STROKE_COLOR = "SVGStrokeColor";
+ public static final String SVG_STROKE_WIDTH = "SVGStrokeWidth";
private static final AtmosphericModel ISA_ATMOSPHERIC_MODEL = new ExtendedISAModel();
@@ -1098,6 +1103,42 @@ public abstract class Preferences implements ChangeSource {
return options;
}
+
+ /**
+ * Returns the stroke color used for the SVG.
+ *
+ * @return the stroke color for the SVG
+ */
+ public Color getSVGStrokeColor() {
+ return getColor(SVG_STROKE_COLOR, ORColor.fromAWTColor(Color.BLACK)).toAWTColor();
+ }
+
+ /**
+ * Sets the stroke color used for the SVG.
+ *
+ * @param c the stroke color to set
+ */
+ public void setSVGStrokeColor(Color c) {
+ putColor(SVG_STROKE_COLOR, ORColor.fromAWTColor(c));
+ }
+
+ /**
+ * Returns the stroke width used for the SVG in mm.
+ *
+ * @return the stroke width for the SVG
+ */
+ public double getSVGStrokeWidth() {
+ return getDouble(SVG_STROKE_WIDTH, 0.1);
+ }
+
+ /**
+ * Sets the stroke width used for the SVG in mm.
+ *
+ * @param width the stroke width to set
+ */
+ public void setSVGStrokeWidth(double width) {
+ putDouble(SVG_STROKE_WIDTH, width);
+ }
/*
* Within a holder class so they will load only when needed.
diff --git a/core/src/net/sf/openrocket/unit/UnitGroup.java b/core/src/net/sf/openrocket/unit/UnitGroup.java
index 86cdda258..933db0280 100644
--- a/core/src/net/sf/openrocket/unit/UnitGroup.java
+++ b/core/src/net/sf/openrocket/unit/UnitGroup.java
@@ -89,6 +89,8 @@ public class UnitGroup {
public static final UnitGroup UNITS_SCALING;
+ public static final UnitGroup UNITS_STROKE_WIDTH;
+
public static final Map UNITS; // keys such as "LENGTH", "VELOCITY"
public static final Map SIUNITS; // keys such a "m", "m/s"
@@ -316,6 +318,12 @@ public class UnitGroup {
UNITS_SCALING = new UnitGroup();
UNITS_SCALING.addUnit(new FixedPrecisionUnit("" + ZWSP, 0.1)); // zero-width space
+ UNITS_STROKE_WIDTH = new UnitGroup();
+ UNITS_STROKE_WIDTH.addUnit(new GeneralUnit(1, "mm"));
+ UNITS_STROKE_WIDTH.addUnit(new GeneralUnit(0.1, MICRO + "m"));
+ //UNITS_STROKE_WIDTH.addUnit(new GeneralUnit(25.4, "in"));
+ UNITS_STROKE_WIDTH.addUnit(new GeneralUnit(0.0254, "mil"));
+
// This is not used by OpenRocket, and not extensively tested:
UNITS_FREQUENCY = new UnitGroup();
@@ -354,6 +362,7 @@ public class UnitGroup {
map.put("ROUGHNESS", UNITS_ROUGHNESS);
map.put("COEFFICIENT", UNITS_COEFFICIENT);
map.put("SCALING", UNITS_SCALING);
+ map.put("STROKE_WIDTH", UNITS_STROKE_WIDTH);
map.put("VOLTAGE", UNITS_VOLTAGE);
map.put("CURRENT", UNITS_CURRENT);
map.put("ENERGY", UNITS_ENERGY);
@@ -416,6 +425,7 @@ public class UnitGroup {
UNITS_PRESSURE.setDefaultUnit("mbar");
UNITS_RELATIVE.setDefaultUnit("%");
UNITS_ROUGHNESS.setDefaultUnit(MICRO + "m");
+ UNITS_STROKE_WIDTH.setDefaultUnit("mm");
}
public static void setDefaultImperialUnits() {
@@ -445,6 +455,7 @@ public class UnitGroup {
UNITS_PRESSURE.setDefaultUnit("mbar");
UNITS_RELATIVE.setDefaultUnit("%");
UNITS_ROUGHNESS.setDefaultUnit("mil");
+ UNITS_STROKE_WIDTH.setDefaultUnit("mil");
}
public static void resetDefaultUnits() {
@@ -485,6 +496,7 @@ public class UnitGroup {
UNITS_ROUGHNESS.setDefaultUnit(0);
UNITS_COEFFICIENT.setDefaultUnit(0);
UNITS_SCALING.setDefaultUnit(0);
+ UNITS_STROKE_WIDTH.setDefaultUnit(0);
UNITS_FREQUENCY.setDefaultUnit(1);
}
diff --git a/swing/src/net/sf/openrocket/gui/components/SVGOptionPanel.java b/swing/src/net/sf/openrocket/gui/components/SVGOptionPanel.java
new file mode 100644
index 000000000..5fd64915a
--- /dev/null
+++ b/swing/src/net/sf/openrocket/gui/components/SVGOptionPanel.java
@@ -0,0 +1,58 @@
+package net.sf.openrocket.gui.components;
+
+import net.miginfocom.swing.MigLayout;
+import net.sf.openrocket.gui.SpinnerEditor;
+import net.sf.openrocket.gui.adaptors.DoubleModel;
+import net.sf.openrocket.l10n.Translator;
+import net.sf.openrocket.startup.Application;
+import net.sf.openrocket.startup.Preferences;
+import net.sf.openrocket.unit.UnitGroup;
+
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JSpinner;
+import java.awt.Color;
+
+public class SVGOptionPanel extends JPanel {
+ private static final Translator trans = Application.getTranslator();
+ private static final Preferences prefs = Application.getPreferences();
+
+ private final ColorChooserButton colorChooser;
+ private double strokeWidth = 0.1;
+
+ public SVGOptionPanel() {
+ super(new MigLayout());
+
+ // Stroke color
+ JLabel label = new JLabel(trans.get("SVGOptionPanel.lbl.strokeColor"));
+ label.setToolTipText(trans.get("SVGOptionPanel.lbl.strokeColor.ttip"));
+ add(label);
+ colorChooser = new ColorChooserButton(prefs.getSVGStrokeColor());
+ colorChooser.setToolTipText(trans.get("SVGOptionPanel.lbl.strokeColor.ttip"));
+ add(colorChooser, "wrap");
+
+ // Stroke width
+ label = new JLabel(trans.get("SVGOptionPanel.lbl.strokeWidth"));
+ label.setToolTipText(trans.get("SVGOptionPanel.lbl.strokeWidth.ttip"));
+ add(label);
+ DoubleModel dm = new DoubleModel(this, "StrokeWidth", UnitGroup.UNITS_STROKE_WIDTH, 0.001, 10);
+ dm.setValue(prefs.getSVGStrokeWidth());
+ JSpinner spin = new JSpinner(dm.getSpinnerModel());
+ spin.setToolTipText(trans.get("SVGOptionPanel.lbl.strokeWidth.ttip"));
+ spin.setEditor(new SpinnerEditor(spin, 5));
+ add(spin);
+ add(new UnitSelector(dm), "growx, wrap");
+ }
+
+ public Color getStrokeColor() {
+ return colorChooser.getSelectedColor();
+ }
+
+ public double getStrokeWidth() {
+ return strokeWidth;
+ }
+
+ public void setStrokeWidth(double strokeWidth) {
+ this.strokeWidth = strokeWidth;
+ }
+}
diff --git a/swing/src/net/sf/openrocket/gui/configdialog/FinSetConfig.java b/swing/src/net/sf/openrocket/gui/configdialog/FinSetConfig.java
index eb45a473f..66f288d21 100644
--- a/swing/src/net/sf/openrocket/gui/configdialog/FinSetConfig.java
+++ b/swing/src/net/sf/openrocket/gui/configdialog/FinSetConfig.java
@@ -1,8 +1,8 @@
package net.sf.openrocket.gui.configdialog;
-import java.awt.Component;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
+import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
@@ -12,6 +12,7 @@ import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JDialog;
+import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSpinner;
@@ -19,14 +20,18 @@ import javax.swing.SwingUtilities;
import net.miginfocom.swing.MigLayout;
import net.sf.openrocket.document.OpenRocketDocument;
+import net.sf.openrocket.file.svg.export.SVGBuilder;
import net.sf.openrocket.gui.SpinnerEditor;
import net.sf.openrocket.gui.adaptors.DoubleModel;
import net.sf.openrocket.gui.adaptors.EnumModel;
import net.sf.openrocket.gui.adaptors.MaterialModel;
import net.sf.openrocket.gui.components.BasicSlider;
+import net.sf.openrocket.gui.components.SVGOptionPanel;
import net.sf.openrocket.gui.components.StyledLabel;
import net.sf.openrocket.gui.components.StyledLabel.Style;
import net.sf.openrocket.gui.components.UnitSelector;
+import net.sf.openrocket.gui.util.FileHelper;
+import net.sf.openrocket.gui.util.SwingPreferences;
import net.sf.openrocket.l10n.Translator;
import net.sf.openrocket.logging.Markers;
import net.sf.openrocket.material.Material;
@@ -38,6 +43,7 @@ import net.sf.openrocket.rocketcomponent.RocketComponent;
import net.sf.openrocket.rocketcomponent.SymmetricComponent;
import net.sf.openrocket.rocketcomponent.position.AxialMethod;
import net.sf.openrocket.startup.Application;
+import net.sf.openrocket.startup.Preferences;
import net.sf.openrocket.unit.UnitGroup;
import net.sf.openrocket.util.Coordinate;
import net.sf.openrocket.util.MathUtil;
@@ -51,6 +57,7 @@ import org.slf4j.LoggerFactory;
public abstract class FinSetConfig extends RocketComponentConfig {
private static final Logger log = LoggerFactory.getLogger(FinSetConfig.class);
private static final Translator trans = Application.getTranslator();
+ private static final Preferences prefs = Application.getPreferences();
private JButton split = null;
@@ -127,15 +134,47 @@ public abstract class FinSetConfig extends RocketComponentConfig {
}
});
split.setEnabled(((FinSet) component).getFinCount() > 1);
+
+ //// Export to SVG
+ JButton exportSVGBtn = new SelectColorButton(trans.get("FinSetConfig.lbl.exportSVG"));
+ exportSVGBtn.setToolTipText(trans.get("FinSetConfig.lbl.exportSVG.ttip"));
+ exportSVGBtn.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ log.info(Markers.USER_MARKER, "Export CSV free-form fin");
+
+ JFileChooser chooser = new JFileChooser();
+ chooser.setFileFilter(FileHelper.SVG_FILTER);
+ chooser.setAccessory(new SVGOptionPanel());
+ chooser.setCurrentDirectory(((SwingPreferences) Application.getPreferences()).getDefaultDirectory());
+
+ if (JFileChooser.APPROVE_OPTION == chooser.showSaveDialog(FinSetConfig.this)){
+ File selectedFile= chooser.getSelectedFile();
+ selectedFile = FileHelper.forceExtension(selectedFile, "svg");
+ if (!FileHelper.confirmWrite(selectedFile, buttonPanel)) {
+ return;
+ }
+
+ SVGOptionPanel svgOptions = (SVGOptionPanel) chooser.getAccessory();
+ prefs.setSVGStrokeColor(svgOptions.getStrokeColor());
+ prefs.setSVGStrokeWidth(svgOptions.getStrokeWidth());
+
+ FinSetConfig.writeSVGFile((FinSet) component, selectedFile, svgOptions);
+ ((SwingPreferences) Application.getPreferences()).setDefaultDirectory(chooser.getCurrentDirectory());
+ }
+ }
+ });
if (convert == null) {
- addButtons(split);
+ addButtons(split, exportSVGBtn);
order.add(split);
+ order.add(exportSVGBtn);
}
else {
- addButtons(split, convert);
+ addButtons(split, convert, exportSVGBtn);
order.add(split);
order.add(convert);
+ order.add(exportSVGBtn);
}
}
@@ -580,4 +619,17 @@ public abstract class FinSetConfig extends RocketComponentConfig {
return filletPanel;
}
+
+ private static boolean writeSVGFile(FinSet finSet, File file, SVGOptionPanel svgOptions) {
+ Coordinate[] points = finSet.getFinPointsWithRoot();
+ try {
+ SVGBuilder builder = new SVGBuilder();
+ builder.addPath(points, null, svgOptions.getStrokeColor(), svgOptions.getStrokeWidth());
+ builder.writeToFile(file);
+ return true;
+ } catch (Exception e) {
+ log.error("Error writing SVG file", e);
+ return false;
+ }
+ }
}
diff --git a/swing/src/net/sf/openrocket/gui/util/FileHelper.java b/swing/src/net/sf/openrocket/gui/util/FileHelper.java
index 1450def77..f291659fe 100644
--- a/swing/src/net/sf/openrocket/gui/util/FileHelper.java
+++ b/swing/src/net/sf/openrocket/gui/util/FileHelper.java
@@ -68,6 +68,10 @@ public final class FileHelper {
public static final FileFilter PNG_FILTER =
new SimpleFileFilter(trans.get("FileHelper.PNG_FILTER"), ".png");
+ /** File filter for CSV files (*.csv) */
+ public static final FileFilter SVG_FILTER =
+ new SimpleFileFilter(trans.get("FileHelper.SVG_FILTER"), ".svg");
+
/** File filter for XML files (*.xml) */
public static final FileFilter XML_FILTER =
new SimpleFileFilter(trans.get("FileHelper.XML_FILTER"), ".xml");