From f645f580ecff99f5763bec985dde19ebd9b217d9 Mon Sep 17 00:00:00 2001 From: SiboVG Date: Tue, 10 Sep 2024 15:24:26 +0200 Subject: [PATCH] [#2060, #2558] Implement multi-level wind input --- .../core/file/openrocket/OpenRocketSaver.java | 27 +- .../importt/SimulationConditionsHandler.java | 28 +- .../file/openrocket/importt/WindHandler.java | 69 ++ .../file/rasaero/export/LaunchSiteDTO.java | 4 +- .../rasaero/importt/LaunchSiteHandler.java | 2 +- .../core/models/wind/MultiLevelWindModel.java | 193 +++++ .../core/models/wind/PinkNoiseWindModel.java | 58 ++ .../core/models/wind/WindModel.java | 34 +- .../core/models/wind/WindModelType.java | 6 + .../preferences/ApplicationPreferences.java | 92 +-- .../DefaultSimulationOptionFactory.java | 13 +- .../core/simulation/SimulationOptions.java | 123 ++- .../SimulationOptionsInterface.java | 27 +- .../core/util/AbstractChangeSource.java | 1 - .../info/openrocket/core/util/MathUtil.java | 12 +- .../main/resources/l10n/messages.properties | 18 + .../models/wind/MultiLevelWindModelTest.java | 155 ++++ .../simulation/SimulationConditionsTest.java | 98 ++- .../preferences/LaunchPreferencesPanel.java | 3 +- .../simulation/SimulationConditionsPanel.java | 747 ++++++++++++++---- .../WindLevelVisualizationDialog.java | 244 ++++++ .../swing/gui/util/SwingPreferences.java | 3 +- 22 files changed, 1610 insertions(+), 347 deletions(-) create mode 100644 core/src/main/java/info/openrocket/core/file/openrocket/importt/WindHandler.java create mode 100644 core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java create mode 100644 core/src/main/java/info/openrocket/core/models/wind/WindModelType.java create mode 100644 core/src/test/java/info/openrocket/core/models/wind/MultiLevelWindModelTest.java create mode 100644 swing/src/main/java/info/openrocket/swing/gui/simulation/WindLevelVisualizationDialog.java diff --git a/core/src/main/java/info/openrocket/core/file/openrocket/OpenRocketSaver.java b/core/src/main/java/info/openrocket/core/file/openrocket/OpenRocketSaver.java index cc841799d..dbce206f7 100644 --- a/core/src/main/java/info/openrocket/core/file/openrocket/OpenRocketSaver.java +++ b/core/src/main/java/info/openrocket/core/file/openrocket/OpenRocketSaver.java @@ -16,6 +16,7 @@ import info.openrocket.core.logging.ErrorSet; import info.openrocket.core.logging.SimulationAbort; import info.openrocket.core.logging.WarningSet; import info.openrocket.core.material.Material; +import info.openrocket.core.models.wind.MultiLevelWindModel; import info.openrocket.core.preferences.DocumentPreferences; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -336,8 +337,30 @@ public class OpenRocketSaver extends RocketSaver { writeElement("launchrodlength", cond.getLaunchRodLength()); writeElement("launchrodangle", cond.getLaunchRodAngle() * 180.0 / Math.PI); writeElement("launchroddirection", cond.getLaunchRodDirection() * 360.0 / (2.0 * Math.PI)); - writeElement("windaverage", cond.getWindSpeedAverage()); - writeElement("windturbulence", cond.getWindTurbulenceIntensity()); + + // TODO: remove once support for OR 23.09 and prior is dropped + writeElement("windaverage", cond.getPinkNoiseWindModel().getAverage()); + writeElement("windturbulence", cond.getPinkNoiseWindModel().getTurbulenceIntensity()); + writeElement("winddirection", cond.getPinkNoiseWindModel().getDirection()); + + writeln(""); + indent++; + writeElement("windaverage", cond.getPinkNoiseWindModel().getAverage()); + writeElement("windturbulence", cond.getPinkNoiseWindModel().getTurbulenceIntensity()); + writeElement("winddirection", cond.getPinkNoiseWindModel().getDirection()); + indent--; + writeln(""); + + if (!cond.getMultiLevelWindModel().getLevels().isEmpty()) { + writeln(""); + indent++; + for (MultiLevelWindModel.WindLevel level : cond.getMultiLevelWindModel().getLevels()) { + writeln(""); + } + indent--; + writeln(""); + } + writeElement("launchaltitude", cond.getLaunchAltitude()); writeElement("launchlatitude", cond.getLaunchLatitude()); writeElement("launchlongitude", cond.getLaunchLongitude()); diff --git a/core/src/main/java/info/openrocket/core/file/openrocket/importt/SimulationConditionsHandler.java b/core/src/main/java/info/openrocket/core/file/openrocket/importt/SimulationConditionsHandler.java index 60302542c..be84187ac 100644 --- a/core/src/main/java/info/openrocket/core/file/openrocket/importt/SimulationConditionsHandler.java +++ b/core/src/main/java/info/openrocket/core/file/openrocket/importt/SimulationConditionsHandler.java @@ -17,6 +17,7 @@ class SimulationConditionsHandler extends AbstractElementHandler { public FlightConfigurationId idToSet = FlightConfigurationId.ERROR_FCID; private final SimulationOptions options; private AtmosphereHandler atmosphereHandler; + private WindHandler windHandler; public SimulationConditionsHandler(Rocket rocket, DocumentLoadingContext context) { this.context = context; @@ -32,7 +33,10 @@ class SimulationConditionsHandler extends AbstractElementHandler { @Override public ElementHandler openElement(String element, HashMap attributes, WarningSet warnings) { - if (element.equals("atmosphere")) { + if (element.equals("wind")) { + windHandler = new WindHandler(attributes.get("model"), options); + return windHandler; + } else if (element.equals("atmosphere")) { atmosphereHandler = new AtmosphereHandler(attributes.get("model"), context); return atmosphereHandler; } @@ -69,19 +73,33 @@ class SimulationConditionsHandler extends AbstractElementHandler { } else { options.setLaunchRodDirection(d * 2.0 * Math.PI / 360); } - } else if (element.equals("windaverage")) { + } + // TODO: remove once support for OR 23.09 and prior is dropped + else if (element.equals("windaverage")) { if (Double.isNaN(d)) { warnings.add("Illegal average windspeed defined, ignoring."); } else { - options.setWindSpeedAverage(d); + options.getPinkNoiseWindModel().setAverage(d); } } else if (element.equals("windturbulence")) { if (Double.isNaN(d)) { warnings.add("Illegal wind turbulence intensity defined, ignoring."); } else { - options.setWindTurbulenceIntensity(d); + options.getPinkNoiseWindModel().setTurbulenceIntensity(d); } - } else if (element.equals("launchaltitude")) { + } else if (element.equals("winddirection")) { + if (Double.isNaN(d)) { + warnings.add("Illegal wind direction defined, ignoring."); + } else { + options.getPinkNoiseWindModel().setDirection(d); + } + } + + else if (element.equals("wind")) { + windHandler.storeSettings(options, warnings); + } + + else if (element.equals("launchaltitude")) { if (Double.isNaN(d)) { warnings.add("Illegal launch altitude defined, ignoring."); } else { diff --git a/core/src/main/java/info/openrocket/core/file/openrocket/importt/WindHandler.java b/core/src/main/java/info/openrocket/core/file/openrocket/importt/WindHandler.java new file mode 100644 index 000000000..9c36b48ec --- /dev/null +++ b/core/src/main/java/info/openrocket/core/file/openrocket/importt/WindHandler.java @@ -0,0 +1,69 @@ +package info.openrocket.core.file.openrocket.importt; + +import info.openrocket.core.file.simplesax.AbstractElementHandler; +import info.openrocket.core.file.simplesax.ElementHandler; +import info.openrocket.core.file.simplesax.PlainTextHandler; +import info.openrocket.core.logging.WarningSet; +import info.openrocket.core.models.wind.WindModelType; +import info.openrocket.core.simulation.SimulationOptions; + +import java.util.HashMap; + +public class WindHandler extends AbstractElementHandler { + private final String model; + private final SimulationOptions options; + + public WindHandler(String model, SimulationOptions options) { + this.model = model; + this.options = options; + } + + @Override + public ElementHandler openElement(String element, HashMap attributes, + WarningSet warnings) { + return PlainTextHandler.INSTANCE; + } + + @Override + public void closeElement(String element, HashMap attributes, + String content, WarningSet warnings) { + double d = Double.NaN; + try { + d = Double.parseDouble(content); + } catch (NumberFormatException ignore) { + } + + if ("pinknoise".equals(model)) { + if (element.equals("windaverage")) { + if (!Double.isNaN(d)) { + options.getPinkNoiseWindModel().setAverage(d); + } + } else if (element.equals("windturbulence")) { + if (!Double.isNaN(d)) { + options.getPinkNoiseWindModel().setTurbulenceIntensity(d); + } + } else if (element.equals("winddirection")) { + if (!Double.isNaN(d)) { + options.getPinkNoiseWindModel().setDirection(d); + } + } + } else if ("multilevel".equals(model)) { + if (element.equals("windlevel")) { + double altitude = Double.parseDouble(attributes.get("altitude")); + double speed = Double.parseDouble(attributes.get("speed")); + double direction = Double.parseDouble(attributes.get("direction")); + options.getMultiLevelWindModel().addWindLevel(altitude, speed, direction); + } + } + } + + public void storeSettings(SimulationOptions options, WarningSet warnings) { + if ("pinknoise".equals(model)) { + options.setWindModelType(WindModelType.PINK_NOISE); + } else if ("multilevel".equals(model)) { + options.setWindModelType(WindModelType.MULTI_LEVEL); + } else { + warnings.add("Unknown wind model type '" + model + "', using default."); + } + } +} diff --git a/core/src/main/java/info/openrocket/core/file/rasaero/export/LaunchSiteDTO.java b/core/src/main/java/info/openrocket/core/file/rasaero/export/LaunchSiteDTO.java index 75e8722ed..ef7995317 100644 --- a/core/src/main/java/info/openrocket/core/file/rasaero/export/LaunchSiteDTO.java +++ b/core/src/main/java/info/openrocket/core/file/rasaero/export/LaunchSiteDTO.java @@ -57,7 +57,7 @@ public class LaunchSiteDTO { setTemperature(RASAeroCommonConstants.OPENROCKET_TO_RASAERO_TEMPERATURE(options.getLaunchTemperature())); setRodAngle(options.getLaunchRodAngle() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_ANGLE); setRodLength(options.getLaunchRodLength() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_ALTITUDE); // It's a length, but stored in RASAero in feet instead of inches - setWindSpeed(options.getWindSpeedAverage() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_SPEED); + setWindSpeed(options.getPinkNoiseWindModel().getAverage() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_SPEED); return; } @@ -68,7 +68,7 @@ public class LaunchSiteDTO { setTemperature(RASAeroCommonConstants.OPENROCKET_TO_RASAERO_TEMPERATURE(prefs.getLaunchTemperature())); setRodAngle(prefs.getLaunchRodAngle() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_ANGLE); setRodLength(prefs.getLaunchRodLength() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_ALTITUDE); // It's a length, but stored in RASAero in feet instead of inches - setWindSpeed(prefs.getWindSpeedAverage() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_SPEED); + setWindSpeed(prefs.getPinkNoiseWindModel().getAverage() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_SPEED); } public Double getAltitude() { diff --git a/core/src/main/java/info/openrocket/core/file/rasaero/importt/LaunchSiteHandler.java b/core/src/main/java/info/openrocket/core/file/rasaero/importt/LaunchSiteHandler.java index ba81cea3b..551c04366 100644 --- a/core/src/main/java/info/openrocket/core/file/rasaero/importt/LaunchSiteHandler.java +++ b/core/src/main/java/info/openrocket/core/file/rasaero/importt/LaunchSiteHandler.java @@ -59,7 +59,7 @@ public class LaunchSiteHandler extends AbstractElementHandler { launchSiteSettings.setLaunchTemperature( RASAeroCommonConstants.RASAERO_TO_OPENROCKET_TEMPERATURE(Double.parseDouble(content))); } else if (RASAeroCommonConstants.LAUNCH_WIND_SPEED.equals(element)) { - launchSiteSettings.setWindSpeedAverage( + launchSiteSettings.getPinkNoiseWindModel().setAverage( Double.parseDouble(content) / RASAeroCommonConstants.OPENROCKET_TO_RASAERO_SPEED); } } catch (NumberFormatException e) { diff --git a/core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java b/core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java new file mode 100644 index 000000000..ecc2202c7 --- /dev/null +++ b/core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java @@ -0,0 +1,193 @@ +package info.openrocket.core.models.wind; + +import java.util.ArrayList; +import java.util.List; +import java.util.Collections; +import java.util.Comparator; +import info.openrocket.core.util.Coordinate; +import info.openrocket.core.util.MathUtil; +import info.openrocket.core.util.ModID; + +public class MultiLevelWindModel implements WindModel { + + private List levels; + + public MultiLevelWindModel() { + this.levels = new ArrayList<>(); + } + + public void addWindLevel(double altitude, double speed, double direction) { + WindLevel newLevel = new WindLevel(altitude, speed, direction); + int index = Collections.binarySearch(levels, newLevel, Comparator.comparingDouble(l -> l.altitude)); + if (index >= 0) { + throw new IllegalArgumentException("Wind level already exists for altitude: " + altitude); + } + levels.add(-index - 1, newLevel); + } + + public void removeWindLevel(double altitude) { + levels.removeIf(level -> level.altitude == altitude); + } + + public void removeWindLevelIdx(int index) { + levels.remove(index); + } + + public List getLevels() { + return new ArrayList<>(levels); + } + + public void resortLevels() { + levels.sort(Comparator.comparingDouble(l -> l.altitude)); + } + + @Override + public Coordinate getWindVelocity(double time, double altitude) { + if (levels.isEmpty()) { + return Coordinate.ZERO; + } + + int index = Collections.binarySearch(levels, new WindLevel(altitude, 0, 0), + Comparator.comparingDouble(l -> l.altitude)); + + // Retrieve the wind level if it exists + if (index >= 0) { + return levels.get(index).toCoordinate(); + } + + // Extrapolation (take the value of the outer bounds) + int insertionPoint = -index - 1; + if (insertionPoint == 0) { + return levels.get(0).toCoordinate(); + } + if (insertionPoint == levels.size()) { + return levels.get(levels.size() - 1).toCoordinate(); + } + + // Interpolation (take the value between the closest two bounds) + WindLevel lower = levels.get(insertionPoint - 1); + WindLevel upper = levels.get(insertionPoint); + + double fraction = (altitude - lower.altitude) / (upper.altitude - lower.altitude); + double speed = MathUtil.interpolate(lower.speed, upper.speed, fraction); + double direction = MathUtil.interpolate(lower.direction, upper.direction, fraction); + + return new Coordinate(speed * Math.sin(direction), speed * Math.cos(direction), 0); + } + + public double getWindDirection(double altitude) { + if (levels.isEmpty()) { + return 0; + } + + int index = Collections.binarySearch(levels, new WindLevel(altitude, 0, 0), + Comparator.comparingDouble(l -> l.altitude)); + + if (index >= 0) { + return levels.get(index).direction; + } + + int insertionPoint = -index - 1; + if (insertionPoint == 0) { + return levels.get(0).direction; + } + if (insertionPoint == levels.size()) { + return levels.get(levels.size() - 1).direction; + } + + WindLevel lower = levels.get(insertionPoint - 1); + WindLevel upper = levels.get(insertionPoint); + + double fraction = (altitude - lower.altitude) / (upper.altitude - lower.altitude); + return MathUtil.interpolate(lower.direction, upper.direction, fraction); + } + + @Override + public ModID getModID() { + return ModID.ZERO; // You might want to create a specific ModID for this model + } + + public void loadFrom(MultiLevelWindModel source) { + this.levels.clear(); + for (WindLevel level : source.levels) { + this.levels.add(level.clone()); + } + } + + @Override + public MultiLevelWindModel clone() { + try { + MultiLevelWindModel clone = (MultiLevelWindModel) super.clone(); + clone.levels = new ArrayList<>(this.levels.size()); + clone.loadFrom(this); + return clone; + } catch (CloneNotSupportedException e) { + throw new AssertionError(); // This should never happen + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MultiLevelWindModel that = (MultiLevelWindModel) o; + return levels.equals(that.levels); + } + + @Override + public int hashCode() { + return levels.hashCode(); + } + + public static class WindLevel implements Cloneable { + public double altitude; + public double speed; + public double direction; + + public WindLevel(double altitude, double speed, double direction) { + this.altitude = altitude; + this.speed = speed; + this.direction = direction; + } + + Coordinate toCoordinate() { + return new Coordinate(speed * Math.sin(direction), speed * Math.cos(direction), 0); + } + + public void loadFrom(WindLevel source) { + this.altitude = source.altitude; + this.speed = source.speed; + this.direction = source.direction; + } + + @Override + public WindLevel clone() { + try { + WindLevel clone = (WindLevel) super.clone(); + clone.loadFrom(this); + return clone; + } catch (CloneNotSupportedException e) { + throw new AssertionError(); // This should never happen + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + WindLevel windLevel = (WindLevel) o; + return Double.compare(windLevel.altitude, altitude) == 0 && + Double.compare(windLevel.speed, speed) == 0 && + Double.compare(windLevel.direction, direction) == 0; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + Double.hashCode(altitude); + result = 31 * result + Double.hashCode(speed); + result = 31 * result + Double.hashCode(direction); + return result; + } + } +} \ No newline at end of file diff --git a/core/src/main/java/info/openrocket/core/models/wind/PinkNoiseWindModel.java b/core/src/main/java/info/openrocket/core/models/wind/PinkNoiseWindModel.java index 6885c063d..3349320fa 100644 --- a/core/src/main/java/info/openrocket/core/models/wind/PinkNoiseWindModel.java +++ b/core/src/main/java/info/openrocket/core/models/wind/PinkNoiseWindModel.java @@ -1,11 +1,14 @@ package info.openrocket.core.models.wind; +import java.util.ArrayList; +import java.util.List; import java.util.Random; import info.openrocket.core.util.Coordinate; import info.openrocket.core.util.MathUtil; import info.openrocket.core.util.ModID; import info.openrocket.core.util.PinkNoise; +import info.openrocket.core.util.StateChangeListener; /** * A wind simulator that generates wind speed as pink noise from a specified @@ -55,6 +58,10 @@ public class PinkNoiseWindModel implements WindModel { this.seed = seed ^ SEED_RANDOMIZATION; } + public PinkNoiseWindModel() { + this(new Random().nextInt()); + } + /** * Return the average wind speed. * @@ -71,13 +78,21 @@ public class PinkNoiseWindModel implements WindModel { * @param average the average wind speed to set */ public void setAverage(double average) { + if (average == this.average) { + return; + } double intensity = getTurbulenceIntensity(); this.average = Math.max(average, 0); setTurbulenceIntensity(intensity); + fireChangeEvent(); } public void setDirection(double direction) { + if (direction == this.direction) { + return; + } this.direction = direction; + fireChangeEvent(); } public double getDirection() { @@ -99,7 +114,12 @@ public class PinkNoiseWindModel implements WindModel { * @param standardDeviation the standardDeviation to set */ public void setStandardDeviation(double standardDeviation) { + if (standardDeviation == this.standardDeviation) { + return; + } this.standardDeviation = Math.max(standardDeviation, 0); + setTurbulenceIntensity(standardDeviation / average); + fireChangeEvent(); } /** @@ -161,9 +181,47 @@ public class PinkNoiseWindModel implements WindModel { randomSource = null; } + public void loadFrom(PinkNoiseWindModel source) { + this.average = source.average; + this.direction = source.direction; + this.standardDeviation = source.standardDeviation; + } + @Override public ModID getModID() { return ModID.ZERO; } + @Override + public PinkNoiseWindModel clone() { + try { + PinkNoiseWindModel clone = (PinkNoiseWindModel) super.clone(); + clone.loadFrom(this); + return clone; + } catch (CloneNotSupportedException e) { + throw new AssertionError(); // This should never happen + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PinkNoiseWindModel that = (PinkNoiseWindModel) o; + return Double.compare(that.average, average) == 0 && + Double.compare(that.standardDeviation, standardDeviation) == 0 && + Double.compare(that.direction, direction) == 0 && + seed == that.seed; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + Double.hashCode(average); + result = 31 * result + Double.hashCode(standardDeviation); + result = 31 * result + Double.hashCode(direction); + result = 31 * result + seed; + return result; + } + } diff --git a/core/src/main/java/info/openrocket/core/models/wind/WindModel.java b/core/src/main/java/info/openrocket/core/models/wind/WindModel.java index 1ac483252..2e4150ef8 100644 --- a/core/src/main/java/info/openrocket/core/models/wind/WindModel.java +++ b/core/src/main/java/info/openrocket/core/models/wind/WindModel.java @@ -1,10 +1,40 @@ package info.openrocket.core.models.wind; +import info.openrocket.core.util.ChangeSource; import info.openrocket.core.util.Coordinate; import info.openrocket.core.util.Monitorable; +import info.openrocket.core.util.StateChangeListener; -public interface WindModel extends Monitorable { +import java.util.ArrayList; +import java.util.EventListener; +import java.util.EventObject; +import java.util.List; - public Coordinate getWindVelocity(double time, double altitude); +public interface WindModel extends Monitorable, Cloneable, ChangeSource { + List listeners = new ArrayList<>(); + Coordinate getWindVelocity(double time, double altitude); + + WindModel clone(); + + @Override + default void addChangeListener(StateChangeListener listener) { + listeners.add(listener); + } + + @Override + default void removeChangeListener(StateChangeListener listener) { + listeners.remove(listener); + } + + default void fireChangeEvent() { + EventObject event = new EventObject(this); + // Copy the list before iterating to prevent concurrent modification exceptions. + EventListener[] list = listeners.toArray(new EventListener[0]); + for (EventListener l : list) { + if (l instanceof StateChangeListener) { + ((StateChangeListener) l).stateChanged(event); + } + } + } } diff --git a/core/src/main/java/info/openrocket/core/models/wind/WindModelType.java b/core/src/main/java/info/openrocket/core/models/wind/WindModelType.java new file mode 100644 index 000000000..3097a8f91 --- /dev/null +++ b/core/src/main/java/info/openrocket/core/models/wind/WindModelType.java @@ -0,0 +1,6 @@ +package info.openrocket.core.models.wind; + +public enum WindModelType { + PINK_NOISE, + MULTI_LEVEL +} diff --git a/core/src/main/java/info/openrocket/core/preferences/ApplicationPreferences.java b/core/src/main/java/info/openrocket/core/preferences/ApplicationPreferences.java index 8666e5f5a..cb5da8159 100644 --- a/core/src/main/java/info/openrocket/core/preferences/ApplicationPreferences.java +++ b/core/src/main/java/info/openrocket/core/preferences/ApplicationPreferences.java @@ -19,12 +19,14 @@ import info.openrocket.core.file.wavefrontobj.export.OBJExportOptions; import info.openrocket.core.material.Material; import info.openrocket.core.models.atmosphere.AtmosphericModel; import info.openrocket.core.models.atmosphere.ExtendedISAModel; +import info.openrocket.core.models.wind.PinkNoiseWindModel; import info.openrocket.core.preset.ComponentPreset; import info.openrocket.core.rocketcomponent.FlightConfiguration; import info.openrocket.core.rocketcomponent.MassObject; import info.openrocket.core.rocketcomponent.Rocket; import info.openrocket.core.rocketcomponent.RocketComponent; import info.openrocket.core.simulation.RK4SimulationStepper; +import info.openrocket.core.simulation.SimulationOptionsInterface; import info.openrocket.core.startup.Application; import info.openrocket.core.util.BugException; import info.openrocket.core.util.BuildProperties; @@ -35,7 +37,7 @@ import info.openrocket.core.util.LineStyle; import info.openrocket.core.util.MathUtil; import info.openrocket.core.util.StateChangeListener; -public abstract class ApplicationPreferences implements ChangeSource, ORPreferences { +public abstract class ApplicationPreferences implements ChangeSource, ORPreferences, SimulationOptionsInterface, StateChangeListener { private static final String SPLIT_CHARACTER = "|"; /* @@ -152,7 +154,10 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen public static final String SVG_STROKE_WIDTH = "SVGStrokeWidth"; private static final AtmosphericModel ISA_ATMOSPHERIC_MODEL = new ExtendedISAModel(); - + + private PinkNoiseWindModel pinkNoiseWindModel = null; + + /* * ****************************************************************************************** * @@ -346,19 +351,6 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen fireChangeEvent(); } - public final double getWindTurbulenceIntensity() { - return Application.getPreferences().getChoice(ApplicationPreferences.WIND_TURBULENCE, 0.9, 0.1); - } - - public final void setWindTurbulenceIntensity(double wti) { - double oldWTI = Application.getPreferences().getChoice(ApplicationPreferences.WIND_TURBULENCE, 0.9, 0.3); - - if (MathUtil.equals(oldWTI, wti)) - return; - this.putDouble(ApplicationPreferences.WIND_TURBULENCE, wti); - fireChangeEvent(); - } - public double getLaunchRodLength() { return this.getDouble(LAUNCH_ROD_LENGTH, 1); } @@ -399,49 +391,34 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen fireChangeEvent(); } - - - public double getWindSpeedAverage() { - return this.getDouble(WIND_AVERAGE, 2); + + + protected void loadWindModelState() { + double average = getDouble(WIND_AVERAGE, 2.0); + double turbulenceIntensity = getDouble(WIND_TURBULENCE, 0.1); + double direction = getDouble(WIND_DIRECTION, Math.PI / 2); + + getPinkNoiseWindModel().setAverage(average); + getPinkNoiseWindModel().setTurbulenceIntensity(turbulenceIntensity); + getPinkNoiseWindModel().setDirection(direction); } - - public void setWindSpeedAverage(double windAverage) { - if (MathUtil.equals(this.getDouble(WIND_AVERAGE, 2), windAverage)) - return; - this.putDouble(WIND_AVERAGE, MathUtil.max(windAverage, 0)); - fireChangeEvent(); + + protected void storeWindModelState() { + putDouble(WIND_AVERAGE, getPinkNoiseWindModel().getAverage()); + putDouble(WIND_TURBULENCE, getPinkNoiseWindModel().getTurbulenceIntensity()); + putDouble(WIND_DIRECTION, getPinkNoiseWindModel().getDirection()); } - - - public double getWindSpeedDeviation() { - return this.getDouble(WIND_AVERAGE, 2) * this.getDouble(WIND_TURBULENCE, 0.1); - } - - public void setWindSpeedDeviation(double windDeviation) { - double windAverage = this.getDouble(WIND_DIRECTION, 2); - if (windAverage < 0.1) { - windAverage = 0.1; + + @Override + public PinkNoiseWindModel getPinkNoiseWindModel() { + if (pinkNoiseWindModel == null) { + pinkNoiseWindModel = new PinkNoiseWindModel(); + pinkNoiseWindModel.addChangeListener(this); + loadWindModelState(); } - setWindTurbulenceIntensity(windDeviation / windAverage); + return pinkNoiseWindModel; } - - public void setWindDirection(double direction) { - direction = MathUtil.reduce2Pi(direction); - if (this.getBoolean(LAUNCH_INTO_WIND, true)) { - this.setLaunchRodDirection(direction); - } - if (MathUtil.equals(this.getDouble(WIND_DIRECTION, Math.PI / 2), direction)) - return; - this.putDouble(WIND_DIRECTION, direction); - fireChangeEvent(); - - } - - public double getWindDirection() { - return this.getDouble(WIND_DIRECTION, Math.PI / 2); - - } - + public double getLaunchAltitude() { return this.getDouble(LAUNCH_ALTITUDE, 0); } @@ -1291,4 +1268,11 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen } } } + + @Override + public void stateChanged(EventObject e) { + if (e.getSource() == pinkNoiseWindModel) { + storeWindModelState(); + } + } } diff --git a/core/src/main/java/info/openrocket/core/simulation/DefaultSimulationOptionFactory.java b/core/src/main/java/info/openrocket/core/simulation/DefaultSimulationOptionFactory.java index c90af2d5e..2fc981ba1 100644 --- a/core/src/main/java/info/openrocket/core/simulation/DefaultSimulationOptionFactory.java +++ b/core/src/main/java/info/openrocket/core/simulation/DefaultSimulationOptionFactory.java @@ -35,10 +35,9 @@ public class DefaultSimulationOptionFactory { SimulationOptions defaults = new SimulationOptions(); if (prefs != null) { - defaults.setWindSpeedAverage(prefs.getDouble(SIMCONDITION_WIND_SPEED, defaults.getWindSpeedAverage())); - defaults.setWindSpeedDeviation(prefs.getDouble(SIMCONDITION_WIND_STDDEV, defaults.getWindSpeedDeviation())); - defaults.setWindTurbulenceIntensity( - prefs.getDouble(SIMCONDITION_WIND_TURB, defaults.getWindTurbulenceIntensity())); + defaults.getPinkNoiseWindModel().setAverage(prefs.getPinkNoiseWindModel().getAverage()); + defaults.getPinkNoiseWindModel().setStandardDeviation(prefs.getPinkNoiseWindModel().getStandardDeviation()); + defaults.getPinkNoiseWindModel().setTurbulenceIntensity(prefs.getPinkNoiseWindModel().getTurbulenceIntensity()); defaults.setLaunchLatitude(prefs.getDouble(SIMCONDITION_SITE_LAT, defaults.getLaunchLatitude())); defaults.setLaunchLongitude(prefs.getDouble(SIMCONDITION_SITE_LON, defaults.getLaunchLongitude())); @@ -59,9 +58,9 @@ public class DefaultSimulationOptionFactory { public void saveDefault(SimulationOptions newDefaults) { - prefs.putDouble(SIMCONDITION_WIND_SPEED, newDefaults.getWindSpeedAverage()); - prefs.putDouble(SIMCONDITION_WIND_STDDEV, newDefaults.getWindSpeedDeviation()); - prefs.putDouble(SIMCONDITION_WIND_TURB, newDefaults.getWindTurbulenceIntensity()); + prefs.putDouble(SIMCONDITION_WIND_SPEED, newDefaults.getPinkNoiseWindModel().getAverage()); + prefs.putDouble(SIMCONDITION_WIND_STDDEV, newDefaults.getPinkNoiseWindModel().getStandardDeviation()); + prefs.putDouble(SIMCONDITION_WIND_TURB, newDefaults.getPinkNoiseWindModel().getTurbulenceIntensity()); prefs.putDouble(SIMCONDITION_SITE_LAT, newDefaults.getLaunchLatitude()); prefs.putDouble(SIMCONDITION_SITE_LON, newDefaults.getLaunchLongitude()); diff --git a/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java b/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java index 55b749568..7aa443cfc 100644 --- a/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java +++ b/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java @@ -6,6 +6,9 @@ import java.util.EventObject; import java.util.List; import java.util.Random; +import info.openrocket.core.models.wind.MultiLevelWindModel; +import info.openrocket.core.models.wind.WindModel; +import info.openrocket.core.models.wind.WindModelType; import info.openrocket.core.preferences.ApplicationPreferences; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -54,12 +57,8 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt private double launchRodLength = preferences.getDouble(ApplicationPreferences.LAUNCH_ROD_LENGTH, 1); private boolean launchIntoWind = preferences.getBoolean(ApplicationPreferences.LAUNCH_INTO_WIND, true); private double launchRodAngle = preferences.getDouble(ApplicationPreferences.LAUNCH_ROD_ANGLE, 0); - private double windDirection = preferences.getDouble(ApplicationPreferences.WIND_DIRECTION, Math.PI / 2); private double launchRodDirection = preferences.getDouble(ApplicationPreferences.LAUNCH_ROD_DIRECTION, Math.PI / 2); - private double windAverage = preferences.getDouble(ApplicationPreferences.WIND_AVERAGE, 2.0); - private double windTurbulence = preferences.getDouble(ApplicationPreferences.WIND_TURBULENCE, 0.1); - /* * SimulationOptions maintains the launch site parameters as separate double values, * and converts them into a WorldCoordinate when converting to SimulationConditions. @@ -81,7 +80,13 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt private List listeners = new ArrayList<>(); + private WindModelType windModelType = WindModelType.PINK_NOISE; + private final PinkNoiseWindModel pinkNoiseWindModel; + private final MultiLevelWindModel multiLevelWindModel; + public SimulationOptions() { + pinkNoiseWindModel = new PinkNoiseWindModel(randomSeed); + multiLevelWindModel = new MultiLevelWindModel(); } public double getLaunchRodLength() { @@ -120,6 +125,12 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt public double getLaunchRodDirection() { if (launchIntoWind) { + double windDirection; + if (windModelType == WindModelType.PINK_NOISE) { + windDirection = pinkNoiseWindModel.getDirection(); + } else { + windDirection = multiLevelWindModel.getWindDirection(launchAltitude); + } this.setLaunchRodDirection(windDirection); } return launchRodDirection; @@ -133,57 +144,33 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt fireChangeEvent(); } - public double getWindSpeedAverage() { - return windAverage; + public WindModelType getWindModelType() { + return windModelType; } - public void setWindSpeedAverage(double windAverage) { - if (MathUtil.equals(this.windAverage, windAverage)) - return; - this.windAverage = MathUtil.max(windAverage, 0); - if (MathUtil.equals(this.windAverage, 0)) { - setWindTurbulenceIntensity(0); + public void setWindModelType(WindModelType windModelType) { + if (this.windModelType != windModelType) { + this.windModelType = windModelType; + fireChangeEvent(); } - fireChangeEvent(); } - public double getWindSpeedDeviation() { - return windAverage * windTurbulence; - } - - public void setWindSpeedDeviation(double windDeviation) { - if (windAverage < 0.1) { - windAverage = 0.1; + public WindModel getWindModel() { + if (windModelType == WindModelType.PINK_NOISE) { + return pinkNoiseWindModel; + } else if (windModelType == WindModelType.MULTI_LEVEL) { + return multiLevelWindModel; + } else { + throw new IllegalArgumentException("Unknown wind model type: " + windModelType); } - setWindTurbulenceIntensity(windDeviation / windAverage); } - public double getWindTurbulenceIntensity() { - return windTurbulence; + public PinkNoiseWindModel getPinkNoiseWindModel() { + return pinkNoiseWindModel; } - public void setWindTurbulenceIntensity(double intensity) { - // Does not check equality so that setWindSpeedDeviation can be sure of event - // firing - this.windTurbulence = intensity; - fireChangeEvent(); - } - - public void setWindDirection(double direction) { - direction = MathUtil.reduce2Pi(direction); - if (launchIntoWind) { - this.setLaunchRodDirection(direction); - } - if (MathUtil.equals(this.windDirection, direction)) - return; - this.windDirection = direction; - fireChangeEvent(); - - } - - public double getWindDirection() { - return this.windDirection; - + public MultiLevelWindModel getMultiLevelWindModel() { + return multiLevelWindModel; } public double getLaunchAltitude() { @@ -361,6 +348,16 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt // only do it if one of the "important" (user specified) parameters has really // changed. boolean isChanged = false; + + if (!this.pinkNoiseWindModel.equals(src.pinkNoiseWindModel)) { + isChanged = true; + this.pinkNoiseWindModel.loadFrom(src.pinkNoiseWindModel); + } + if (!this.multiLevelWindModel.equals(src.multiLevelWindModel)) { + isChanged = true; + this.multiLevelWindModel.loadFrom(src.multiLevelWindModel); + } + if (this.launchAltitude != src.launchAltitude) { isChanged = true; this.launchAltitude = src.launchAltitude; @@ -405,18 +402,7 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt isChanged = true; this.maximumAngle = src.maximumAngle; } - if (this.windAverage != src.windAverage) { - isChanged = true; - this.windAverage = src.windAverage; - } - if (this.windDirection != src.windDirection) { - isChanged = true; - this.windDirection = src.windDirection; - } - if (this.windTurbulence != src.windTurbulence) { - isChanged = true; - this.windTurbulence = src.windTurbulence; - } + if (this.timeStep != src.timeStep) { isChanged = true; this.timeStep = src.timeStep; @@ -453,10 +439,10 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt MathUtil.equals(this.launchRodLength, o.launchRodLength) && MathUtil.equals(this.launchTemperature, o.launchTemperature) && MathUtil.equals(this.maximumAngle, o.maximumAngle) && - MathUtil.equals(this.timeStep, o.timeStep) && - MathUtil.equals(this.windAverage, o.windAverage) && - MathUtil.equals(this.windTurbulence, o.windTurbulence) && - MathUtil.equals(this.windDirection, o.windDirection)); + MathUtil.equals(this.timeStep, o.timeStep)) && + this.windModelType == o.windModelType && + this.pinkNoiseWindModel.equals(o.pinkNoiseWindModel) && + this.multiLevelWindModel.equals(o.multiLevelWindModel); } /** @@ -505,17 +491,10 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt conditions.setGeodeticComputation(getGeodeticComputation()); conditions.setRandomSeed(randomSeed); - PinkNoiseWindModel windModel = new PinkNoiseWindModel(randomSeed); - windModel.setAverage(getWindSpeedAverage()); - windModel.setStandardDeviation(getWindSpeedDeviation()); - windModel.setDirection(windDirection); - + WindModel windModel = getWindModel().clone(); conditions.setWindModel(windModel); - conditions.setAtmosphericModel(getAtmosphericModel()); - GravityModel gravityModel = new WGSGravityModel(); - conditions.setGravityModel(gravityModel); conditions.setAerodynamicCalculator(new BarrowmanCalculator()); @@ -533,10 +512,10 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt .concat(String.format(" launchRodLength: %f\n", launchRodLength)) .concat(String.format(" launchIntoWind: %b\n", launchIntoWind)) .concat(String.format(" launchRodAngle: %f\n", launchRodAngle)) - .concat(String.format(" windDirection: %f\n", windDirection)) .concat(String.format(" launchRodDirection: %f\n", launchRodDirection)) - .concat(String.format(" windAverage: %f\n", windAverage)) - .concat(String.format(" windTurbulence: %f\n", windTurbulence)) + .concat(String.format(" windModelType: %s\n", windModelType)) + .concat(String.format(" pinkNoiseWindModel: %s\n", pinkNoiseWindModel)) + .concat(String.format(" multiLevelWindModel: %s\n", multiLevelWindModel)) .concat(String.format(" launchAltitude: %f\n", launchAltitude)) .concat(String.format(" launchLatitude: %f\n", launchLatitude)) .concat(String.format(" launchLongitude: %f\n", launchLongitude)) diff --git a/core/src/main/java/info/openrocket/core/simulation/SimulationOptionsInterface.java b/core/src/main/java/info/openrocket/core/simulation/SimulationOptionsInterface.java index 7e8bb51dc..9843098b5 100644 --- a/core/src/main/java/info/openrocket/core/simulation/SimulationOptionsInterface.java +++ b/core/src/main/java/info/openrocket/core/simulation/SimulationOptionsInterface.java @@ -1,5 +1,6 @@ package info.openrocket.core.simulation; +import info.openrocket.core.models.wind.PinkNoiseWindModel; import info.openrocket.core.util.ChangeSource; import info.openrocket.core.util.GeodeticComputationStrategy; @@ -20,31 +21,7 @@ public interface SimulationOptionsInterface extends ChangeSource { void setLaunchRodDirection(double launchRodDirection); - double getWindSpeedAverage(); - - void setWindSpeedAverage(double windAverage); - - double getWindSpeedDeviation(); - - void setWindSpeedDeviation(double windDeviation); - - /** - * Return the wind turbulence intensity (standard deviation / average). - * - * @return the turbulence intensity - */ - double getWindTurbulenceIntensity(); - - /** - * Set the wind standard deviation to match the given turbulence intensity. - * - * @param intensity the turbulence intensity - */ - void setWindTurbulenceIntensity(double intensity); - - void setWindDirection(double direction); - - double getWindDirection(); + PinkNoiseWindModel getPinkNoiseWindModel(); double getLaunchAltitude(); diff --git a/core/src/main/java/info/openrocket/core/util/AbstractChangeSource.java b/core/src/main/java/info/openrocket/core/util/AbstractChangeSource.java index d5d5a285b..e25e49a94 100644 --- a/core/src/main/java/info/openrocket/core/util/AbstractChangeSource.java +++ b/core/src/main/java/info/openrocket/core/util/AbstractChangeSource.java @@ -42,7 +42,6 @@ public class AbstractChangeSource implements ChangeSource { ((StateChangeListener) l).stateChanged(event); } } - } /** diff --git a/core/src/main/java/info/openrocket/core/util/MathUtil.java b/core/src/main/java/info/openrocket/core/util/MathUtil.java index 64bc4a14a..9ae7ed656 100644 --- a/core/src/main/java/info/openrocket/core/util/MathUtil.java +++ b/core/src/main/java/info/openrocket/core/util/MathUtil.java @@ -331,7 +331,6 @@ public class MathUtil { * or if t is outsize the domain. */ public static double interpolate(List domain, List range, double t) { - if (domain == null || range == null || domain.size() != range.size()) { return Double.NaN; } @@ -368,7 +367,16 @@ public class MathUtil { } return range.get(left) + (t - domain.get(left)) * deltay / deltax; - } + /** + * Use interpolation to determine the value of the function at point t. + * @param a the lower bound + * @param b the upper bound + * @param fraction the fraction between a and b + * @return the interpolated value + */ + public static double interpolate(double a, double b, double fraction) { + return a + (b - a) * fraction; + } } diff --git a/core/src/main/resources/l10n/messages.properties b/core/src/main/resources/l10n/messages.properties index 8ea9ca2cc..0ae8101a1 100644 --- a/core/src/main/resources/l10n/messages.properties +++ b/core/src/main/resources/l10n/messages.properties @@ -503,6 +503,10 @@ simedtdlg.lbl.ttip.Pressure = The atmospheric pressure at the launch site. simedtdlg.lbl.Launchsite = Launch site simedtdlg.lbl.Latitude = Latitude: simedtdlg.lbl.ttip.Latitude = The launch site latitude affects the gravitational pull of Earth.
Positive values are on the Northern hemisphere, negative values on the Southern hemisphere. +simedtdlg.col.Altitude = Altitude +simedtdlg.col.Speed = Speed +simedtdlg.col.Direction = Direction +simedtdlg.col.Unit = Unit simedtdlg.lbl.Longitude = Longitude: simedtdlg.lbl.ttip.Longitude = Required for weather prediction and elevation models. @@ -552,6 +556,20 @@ simedtdlg.IntensityDesc.Medium = Medium simedtdlg.IntensityDesc.High = High simedtdlg.IntensityDesc.Veryhigh = Very high simedtdlg.IntensityDesc.Extreme = Extreme +simedtdlg.lbl.WindModelSelection = Wind model to use: +simedtdlg.radio.PinkNoiseWind = Pink noise +simedtdlg.radio.PinkNoiseWind.ttip = Model the wind as pink noise from the average wind speed and standard deviation. +simedtdlg.radio.MultiLevelWind = Multi-level +simedtdlg.radio.MultiLevelWind.ttip = Model the wind using speed and direction entries at various altitude levels. +simedtdlg.but.addWindLevel = Add level +simedtdlg.but.removeWindLevel = Remove level +simedtdlg.but.visualizeWindLevels = Visualize levels + +! WindLevelVisualizationDialog +WindLevelVisualizationDialog.title.WindLevelVisualization = Wind Level Visualization +WindLevelVisualizationDialog.lbl.WindSpeed = Wind speed +WindLevelVisualizationDialog.lbl.Altitude = Altitude +WindLevelVisualizationDialog.checkbox.ShowDirections = Show wind direction vectors ! SimulationConfigDialog SimulationConfigDialog.tab.Settings = Settings diff --git a/core/src/test/java/info/openrocket/core/models/wind/MultiLevelWindModelTest.java b/core/src/test/java/info/openrocket/core/models/wind/MultiLevelWindModelTest.java new file mode 100644 index 000000000..af4e95b76 --- /dev/null +++ b/core/src/test/java/info/openrocket/core/models/wind/MultiLevelWindModelTest.java @@ -0,0 +1,155 @@ +package info.openrocket.core.models.wind; + +import info.openrocket.core.util.MathUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import info.openrocket.core.util.Coordinate; +import info.openrocket.core.util.ModID; +import info.openrocket.core.util.StateChangeListener; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class MultiLevelWindModelTest { + private final static double EPSILON = MathUtil.EPSILON; + + private MultiLevelWindModel model; + + @BeforeEach + void setUp() { + model = new MultiLevelWindModel(); + } + + @Test + @DisplayName("Add and remove wind levels") + void testAddAndRemoveWindLevels() { + model.addWindLevel(100, 5, Math.PI / 4); + model.addWindLevel(200, 10, Math.PI / 2); + assertEquals(2, model.getLevels().size()); + + model.removeWindLevel(100); + assertEquals(1, model.getLevels().size()); + assertEquals(200, model.getLevels().get(0).altitude, EPSILON); + + model.removeWindLevel(0); + assertEquals(1, model.getLevels().size()); + assertEquals(200, model.getLevels().get(0).altitude, EPSILON); + + model.removeWindLevel(200); + assertTrue(model.getLevels().isEmpty()); + } + + @Test + @DisplayName("Adding duplicate altitude throws IllegalArgumentException") + void testAddDuplicateAltitude() { + model.addWindLevel(100, 5, Math.PI / 4); + assertThrows(IllegalArgumentException.class, () -> model.addWindLevel(100, 10, Math.PI / 2)); + } + + @Test + @DisplayName("Get wind velocity") + void testGetWindVelocity() { + model.addWindLevel(0, 5, 0); + model.addWindLevel(1000, 10, Math.PI / 2); + + verifyWind(500, 7.5, Math.PI / 4); + } + + @Test + @DisplayName("Interpolation between levels") + void testInterpolationBetweenLevels() { + model.addWindLevel(0, 5, 0); + model.addWindLevel(1000, 10, Math.PI); + + verifyWind(200, 6, Math.PI / 5); + verifyWind(500, 7.5, Math.PI / 2); + verifyWind(900, 9.5, 9 * Math.PI / 10); + } + + @Test + @DisplayName("Extrapolation outside levels") + void testExtrapolationOutsideLevels() { + model.addWindLevel(100, 5, 0); + model.addWindLevel(200, 10, Math.PI / 2); + + verifyWind(0, 5, 0); + verifyWind(300, 10, Math.PI / 2); + verifyWind(1000, 10, Math.PI / 2); + } + + @Test + @DisplayName("Resort levels") + void testResortLevels() { + model.addWindLevel(200, 10, Math.PI / 2); + model.addWindLevel(100, 5, Math.PI / 4); + model.addWindLevel(300, 15, 3 * Math.PI / 4); + + model.resortLevels(); + + List levels = model.getLevels(); + assertEquals(3, levels.size()); + assertEquals(100, levels.get(0).altitude, EPSILON); + assertEquals(200, levels.get(1).altitude, EPSILON); + assertEquals(300, levels.get(2).altitude, EPSILON); + } + + @Test + @DisplayName("Clone model") + void testClone() { + model.addWindLevel(100, 5, Math.PI / 4); + model.addWindLevel(200, 10, Math.PI / 2); + + MultiLevelWindModel clonedModel = model.clone(); + assertNotSame(model, clonedModel); + assertEquals(model, clonedModel); + + clonedModel.addWindLevel(300, 15, 3 * Math.PI / 4); + assertNotEquals(model, clonedModel); + } + + @Test + @DisplayName("Load from another model") + void testLoadFrom() { + model.addWindLevel(100, 5, Math.PI / 4); + model.addWindLevel(200, 10, Math.PI / 2); + + MultiLevelWindModel newModel = new MultiLevelWindModel(); + newModel.loadFrom(model); + + assertEquals(model, newModel); + } + + @Test + @DisplayName("Get ModID") + void testGetModID() { + assertEquals(ModID.ZERO, model.getModID()); + } + + @Test + @DisplayName("Change listeners") + void testChangeListeners() { + final boolean[] listenerCalled = {false}; + StateChangeListener listener = event -> listenerCalled[0] = true; + + model.addChangeListener(listener); + model.fireChangeEvent(); + assertTrue(listenerCalled[0]); + + listenerCalled[0] = false; + model.removeChangeListener(listener); + model.fireChangeEvent(); + assertFalse(listenerCalled[0]); + } + + private void verifyWind(double altitude, double expectedSpeed, double expectedDirection) { + Coordinate velocity = model.getWindVelocity(0, altitude); + assertEquals(expectedSpeed, velocity.length(), EPSILON, "Wind speed at altitude " + altitude); + assertEquals(expectedSpeed * Math.sin(expectedDirection), velocity.x, EPSILON, "Wind velocity X component at altitude " + altitude); + assertEquals(expectedSpeed * Math.cos(expectedDirection), velocity.y, EPSILON, "Wind velocity Y component at altitude " + altitude); + assertEquals(0, velocity.z, EPSILON, "Wind velocity Z component at altitude " + altitude); + assertEquals(expectedDirection, Math.atan2(velocity.x, velocity.y), EPSILON, "Wind direction at altitude " + altitude); + } +} \ No newline at end of file diff --git a/core/src/test/java/info/openrocket/core/simulation/SimulationConditionsTest.java b/core/src/test/java/info/openrocket/core/simulation/SimulationConditionsTest.java index 148d9a288..932dd38e3 100644 --- a/core/src/test/java/info/openrocket/core/simulation/SimulationConditionsTest.java +++ b/core/src/test/java/info/openrocket/core/simulation/SimulationConditionsTest.java @@ -10,15 +10,20 @@ import info.openrocket.core.formatting.RocketDescriptor; import info.openrocket.core.formatting.RocketDescriptorImpl; import info.openrocket.core.l10n.DebugTranslator; import info.openrocket.core.l10n.Translator; +import info.openrocket.core.models.wind.MultiLevelWindModel; +import info.openrocket.core.models.wind.PinkNoiseWindModel; import info.openrocket.core.plugin.PluginModule; import info.openrocket.core.preferences.ApplicationPreferences; import info.openrocket.core.startup.Application; import info.openrocket.core.startup.MockPreferences; +import info.openrocket.core.util.Coordinate; import info.openrocket.core.util.MathUtil; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -60,16 +65,99 @@ public class SimulationConditionsTest { assertEquals(Math.PI / 2, options.getLaunchRodDirection(), EPSILON); assertEquals(0.0, options.getLaunchRodAngle(), EPSILON); assertTrue(options.getLaunchIntoWind()); - assertEquals(Math.PI / 2, options.getWindDirection(), EPSILON); - assertEquals(0.1, options.getWindTurbulenceIntensity(), EPSILON); - assertEquals(2.0, options.getWindSpeedAverage(), EPSILON); - assertEquals(0.2, options.getWindSpeedDeviation(), EPSILON); + assertEquals(Math.PI / 2, options.getPinkNoiseWindModel().getDirection(), EPSILON); + assertEquals(0.1, options.getPinkNoiseWindModel().getTurbulenceIntensity(), EPSILON); + assertEquals(2.0, options.getPinkNoiseWindModel().getAverage(), EPSILON); + assertEquals(0.2, options.getPinkNoiseWindModel().getStandardDeviation(), EPSILON); assertEquals(0.05, options.getTimeStep(), EPSILON); assertEquals(3 * Math.PI / 180, options.getMaximumStepAngle(), EPSILON); - } + @Test + @DisplayName("Compare PinkNoiseWindModel and MultiLevelWindModel in SimulationConditions") + public void testWindModelComparison() { + SimulationConditions conditions = new SimulationConditions(); + + // Test PinkNoiseWindModel + PinkNoiseWindModel pinkNoiseModel = new PinkNoiseWindModel(); + pinkNoiseModel.setAverage(5.0); + pinkNoiseModel.setStandardDeviation(1.0); + pinkNoiseModel.setDirection(Math.PI / 4); // 45 degrees + + conditions.setWindModel(pinkNoiseModel); + + Coordinate pinkNoiseVelocity = conditions.getWindModel().getWindVelocity(0, 100); + assertNotNull(pinkNoiseVelocity); + assertTrue(pinkNoiseVelocity.length() > 0); + + // Test MultiLevelWindModel + MultiLevelWindModel multiLevelModel = new MultiLevelWindModel(); + multiLevelModel.addWindLevel(0, 5.0, Math.PI / 4); + multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 2); + + conditions.setWindModel(multiLevelModel); + + Coordinate multiLevelVelocity = conditions.getWindModel().getWindVelocity(0, 100); + assertNotNull(multiLevelVelocity); + assertTrue(multiLevelVelocity.length() > 0); + + // Compare behaviors + assertNotEquals(pinkNoiseVelocity, multiLevelVelocity); + } + + @Test + @DisplayName("Test wind velocity consistency for MultiLevelWindModel") + public void testMultiLevelWindModelConsistency() { + SimulationConditions conditions = new SimulationConditions(); + MultiLevelWindModel multiLevelModel = new MultiLevelWindModel(); + multiLevelModel.addWindLevel(0, 5.0, Math.PI / 4); + multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 2); + + conditions.setWindModel(multiLevelModel); + + Coordinate velocity1 = conditions.getWindModel().getWindVelocity(0, 500); + Coordinate velocity2 = conditions.getWindModel().getWindVelocity(0, 500); + + assertEquals(velocity1, velocity2); + } + + @Test + @DisplayName("Test wind velocity variation for PinkNoiseWindModel") + public void testPinkNoiseWindModelVariation() { + SimulationConditions conditions = new SimulationConditions(); + PinkNoiseWindModel pinkNoiseModel = new PinkNoiseWindModel(); + pinkNoiseModel.setAverage(5.0); + pinkNoiseModel.setStandardDeviation(1.0); + pinkNoiseModel.setDirection(Math.PI / 4); + + conditions.setWindModel(pinkNoiseModel); + + Coordinate velocity1 = conditions.getWindModel().getWindVelocity(0, 100); + Coordinate velocity2 = conditions.getWindModel().getWindVelocity(1, 100); + + assertNotEquals(velocity1, velocity2); + } + + @Test + @DisplayName("Test altitude dependence of MultiLevelWindModel") + public void testMultiLevelWindModelAltitudeDependence() { + SimulationConditions conditions = new SimulationConditions(); + MultiLevelWindModel multiLevelModel = new MultiLevelWindModel(); + multiLevelModel.addWindLevel(0, 5.0, 0); + multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 2); + + conditions.setWindModel(multiLevelModel); + + Coordinate velocityLow = conditions.getWindModel().getWindVelocity(0, 0); + Coordinate velocityHigh = conditions.getWindModel().getWindVelocity(0, 1000); + Coordinate velocityMid = conditions.getWindModel().getWindVelocity(0, 500); + + assertNotEquals(velocityLow, velocityHigh); + assertTrue(velocityMid.length() > velocityLow.length() && velocityMid.length() < velocityHigh.length()); + } + + private static class PreferencesModule extends AbstractModule { @Override protected void configure() { diff --git a/swing/src/main/java/info/openrocket/swing/gui/dialogs/preferences/LaunchPreferencesPanel.java b/swing/src/main/java/info/openrocket/swing/gui/dialogs/preferences/LaunchPreferencesPanel.java index 1d1cea8fa..c27630627 100644 --- a/swing/src/main/java/info/openrocket/swing/gui/dialogs/preferences/LaunchPreferencesPanel.java +++ b/swing/src/main/java/info/openrocket/swing/gui/dialogs/preferences/LaunchPreferencesPanel.java @@ -34,8 +34,7 @@ public class LaunchPreferencesPanel extends PreferencesPanel { add(warning, "spanx, growx 0, gapbottom para, wrap"); // Simulation conditions - SimulationConditionsPanel.addSimulationConditionsPanel(this, preferences); - + SimulationConditionsPanel.addSimulationConditionsPanel(this, preferences, false); } private static void initColors() { diff --git a/swing/src/main/java/info/openrocket/swing/gui/simulation/SimulationConditionsPanel.java b/swing/src/main/java/info/openrocket/swing/gui/simulation/SimulationConditionsPanel.java index 2e0fc0399..bfdbc6351 100644 --- a/swing/src/main/java/info/openrocket/swing/gui/simulation/SimulationConditionsPanel.java +++ b/swing/src/main/java/info/openrocket/swing/gui/simulation/SimulationConditionsPanel.java @@ -1,30 +1,58 @@ package info.openrocket.swing.gui.simulation; +import java.awt.CardLayout; import java.awt.Component; -import java.awt.event.ActionEvent; -import java.awt.event.ActionListener; +import java.awt.Dialog; +import java.awt.Dimension; +import java.awt.Window; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; +import java.awt.event.MouseEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.util.Comparator; import java.util.EventObject; +import java.util.List; import javax.swing.BorderFactory; +import javax.swing.ButtonGroup; +import javax.swing.DefaultCellEditor; import javax.swing.JButton; import javax.swing.JCheckBox; +import javax.swing.JComboBox; import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JPanel; +import javax.swing.JRadioButton; +import javax.swing.JScrollPane; +import javax.swing.JSeparator; import javax.swing.JSpinner; +import javax.swing.JTable; import javax.swing.JTextField; +import javax.swing.ListSelectionModel; +import javax.swing.RowSorter; +import javax.swing.SortOrder; +import javax.swing.SwingUtilities; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; +import javax.swing.table.AbstractTableModel; +import javax.swing.table.DefaultTableCellRenderer; +import javax.swing.table.TableCellRenderer; +import javax.swing.table.TableColumn; +import javax.swing.table.TableColumnModel; +import javax.swing.table.TableRowSorter; import info.openrocket.core.document.Simulation; import info.openrocket.core.l10n.Translator; import info.openrocket.core.models.atmosphere.ExtendedISAModel; +import info.openrocket.core.models.wind.MultiLevelWindModel; +import info.openrocket.core.models.wind.PinkNoiseWindModel; +import info.openrocket.core.models.wind.WindModelType; import info.openrocket.core.simulation.DefaultSimulationOptionFactory; import info.openrocket.core.simulation.SimulationOptions; import info.openrocket.core.simulation.SimulationOptionsInterface; import info.openrocket.core.startup.Application; +import info.openrocket.core.unit.Unit; import info.openrocket.core.unit.UnitGroup; import info.openrocket.core.util.StateChangeListener; @@ -37,55 +65,29 @@ import info.openrocket.swing.gui.components.UnitSelector; public class SimulationConditionsPanel extends JPanel { private static final Translator trans = Application.getTranslator(); - - + + private WindLevelVisualizationDialog visualizationDialog; + SimulationConditionsPanel(final Simulation simulation) { super(new MigLayout("fill")); - - final SimulationOptions conditions = simulation.getOptions(); + + SimulationOptions simulationOptions = simulation.getOptions(); // Simulation conditions settings - addSimulationConditionsPanel(this, conditions); - - - JButton restoreDefaults = new JButton(trans.get("simedtdlg.but.resettodefault")); - restoreDefaults.addActionListener(new ActionListener() { - - @Override - public void actionPerformed(ActionEvent e) { - - DefaultSimulationOptionFactory f = Application.getInjector().getInstance(DefaultSimulationOptionFactory.class); - SimulationOptions defaults = f.getDefault(); - conditions.copyConditionsFrom(defaults); - - } - - }); - this.add(restoreDefaults, "span, split 3, skip, gapbottom para, gapright para, right"); - - JButton saveDefaults = new JButton(trans.get("simedtdlg.but.savedefault")); - saveDefaults.addActionListener(new ActionListener() { - - @Override - public void actionPerformed(ActionEvent e) { - - DefaultSimulationOptionFactory f = Application.getInjector().getInstance(DefaultSimulationOptionFactory.class); - f.saveDefault(conditions); - - } - - }); - - this.add(saveDefaults, "gapbottom para, gapright para, right"); + addSimulationConditionsPanel(this, simulationOptions); + // Add buttons for restoring and saving defaults + addDefaultButtons(simulationOptions); } /** * Adds the simulation conditions panel to the parent panel. * @param parent The parent panel. * @param target The object containing the simulation conditions setters/getters. + * @param addAllWindModels if false, only the pink noise wind model will be added. */ - public static void addSimulationConditionsPanel(JPanel parent, SimulationOptionsInterface target) { + public static void addSimulationConditionsPanel(JPanel parent, SimulationOptionsInterface target, + boolean addAllWindModels) { JPanel sub; DoubleModel pressureModel; DoubleModel m; @@ -96,135 +98,17 @@ public class SimulationConditionsPanel extends JPanel { UnitSelector unit; //// Wind settings: Average wind speed, turbulence intensity, std. deviation, and direction - sub = new JPanel(new MigLayout("fill, gap rel unrel", - "[grow][75lp!][30lp!][75lp!]", "")); + sub = new JPanel(new MigLayout("fill, gap rel unrel", "[grow]", "")); //// Wind sub.setBorder(BorderFactory.createTitledBorder(trans.get("simedtdlg.lbl.Wind"))); parent.add(sub, "growx, split 2, aligny 0, flowy, gapright para"); - - // Wind average - //// Average windspeed: - JLabel label = new JLabel(trans.get("simedtdlg.lbl.Averwindspeed")); - //// The average windspeed relative to the ground. - tip = trans.get("simedtdlg.lbl.ttip.Averwindspeed"); - label.setToolTipText(tip); - sub.add(label); - - DoubleModel windSpeedAverage = new DoubleModel(target, "WindSpeedAverage", UnitGroup.UNITS_WINDSPEED, 0); - - spin = new JSpinner(windSpeedAverage.getSpinnerModel()); - spin.setEditor(new SpinnerEditor(spin)); - spin.setToolTipText(tip); - sub.add(spin, "growx"); - - unit = new UnitSelector(windSpeedAverage); - unit.setToolTipText(tip); - sub.add(unit, "growx"); - slider = new BasicSlider(windSpeedAverage.getSliderModel(0, 10.0)); - slider.setToolTipText(tip); - sub.add(slider, "w 75lp, wrap"); - - - // Wind std. deviation - //// Standard deviation: - label = new JLabel(trans.get("simedtdlg.lbl.Stddeviation")); - //// The standard deviation of the windspeed.
- //// The windspeed is within twice the standard deviation from the average for 95% of the time. - tip = trans.get("simedtdlg.lbl.ttip.Stddeviation"); - label.setToolTipText(tip); - sub.add(label); - - DoubleModel windSpeedDeviation = new DoubleModel(target, "WindSpeedDeviation", UnitGroup.UNITS_WINDSPEED, 0); - DoubleModel m2 = new DoubleModel(target, "WindSpeedAverage", 0.25, UnitGroup.UNITS_COEFFICIENT, 0); - - spin = new JSpinner(windSpeedDeviation.getSpinnerModel()); - spin.setEditor(new SpinnerEditor(spin)); - spin.setToolTipText(tip); - addEasterEgg(spin, parent); - sub.add(spin, "growx"); - - unit = new UnitSelector(windSpeedDeviation); - unit.setToolTipText(tip); - sub.add(unit, "growx"); - slider = new BasicSlider(windSpeedDeviation.getSliderModel(new DoubleModel(0), m2)); - slider.setToolTipText(tip); - sub.add(slider, "w 75lp, wrap"); - - windSpeedAverage.addChangeListener(new ChangeListener() { - @Override - public void stateChanged(ChangeEvent e) { - windSpeedDeviation.stateChanged(e); - } - }); - - - // Wind turbulence intensity - //// Turbulence intensity: - label = new JLabel(trans.get("simedtdlg.lbl.Turbulenceintensity")); - //// The turbulence intensity is the standard deviation divided by the average windspeed.
- //// Typical values range from - //// to - tip = trans.get("simedtdlg.lbl.ttip.Turbulenceintensity1") + - trans.get("simedtdlg.lbl.ttip.Turbulenceintensity2") + " " + - UnitGroup.UNITS_RELATIVE.getDefaultUnit().toStringUnit(0.05) + - " " + trans.get("simedtdlg.lbl.ttip.Turbulenceintensity3") + " " + - UnitGroup.UNITS_RELATIVE.getDefaultUnit().toStringUnit(0.20) + "."; - label.setToolTipText(tip); - sub.add(label); - - DoubleModel windTurbulenceIntensity = new DoubleModel(target, "WindTurbulenceIntensity", UnitGroup.UNITS_RELATIVE, 0); - - spin = new JSpinner(windTurbulenceIntensity.getSpinnerModel()); - spin.setEditor(new SpinnerEditor(spin)); - spin.setToolTipText(tip); - sub.add(spin, "growx"); - - unit = new UnitSelector(windTurbulenceIntensity); - unit.setToolTipText(tip); - sub.add(unit, "growx"); - - final JLabel intensityLabel = new JLabel( - getIntensityDescription(target.getWindTurbulenceIntensity())); - intensityLabel.setToolTipText(tip); - sub.add(intensityLabel, "w 75lp, wrap"); - windTurbulenceIntensity.addChangeListener(new ChangeListener() { - @Override - public void stateChanged(ChangeEvent e) { - intensityLabel.setText( - getIntensityDescription(target.getWindTurbulenceIntensity())); - windSpeedDeviation.stateChanged(e); - } - }); - windSpeedDeviation.addChangeListener(new ChangeListener() { - @Override - public void stateChanged(ChangeEvent e) { - windTurbulenceIntensity.stateChanged(e); - } - }); - - // Wind Direction: - label = new JLabel(trans.get("simedtdlg.lbl.Winddirection")); - //// Direction of the wind. 0 is north - tip = trans.get("simedtdlg.lbl.ttip.Winddirection"); - label.setToolTipText(tip); - sub.add(label); - - m = new DoubleModel(target, "WindDirection", 1.0, UnitGroup.UNITS_ANGLE, - 0, 2*Math.PI); - - spin = new JSpinner(m.getSpinnerModel()); - spin.setEditor(new SpinnerEditor(spin)); - spin.setToolTipText(tip); - sub.add(spin, "growx"); - - unit = new UnitSelector(m); - unit.setToolTipText(tip); - sub.add(unit, "growx"); - slider = new BasicSlider(m.getSliderModel(0, 2*Math.PI)); - slider.setToolTipText(tip); - sub.add(slider, "w 75lp, wrap"); - + // Add wind model selection and configuration panel + if (addAllWindModels) { + addWindModelPanel(sub, target); + } else { + addPinkNoiseSettings(sub, target); + } //// Temperature and pressure sub = new JPanel(new MigLayout("fill, gap rel unrel", @@ -250,7 +134,7 @@ public class SimulationConditionsPanel extends JPanel { sub.add(check, "spanx, wrap unrel"); // Temperature: - label = new JLabel(trans.get("simedtdlg.lbl.Temperature")); + JLabel label = new JLabel(trans.get("simedtdlg.lbl.Temperature")); //// The temperature at the launch site. tip = trans.get("simedtdlg.lbl.ttip.Temperature"); label.setToolTipText(tip); @@ -483,6 +367,302 @@ public class SimulationConditionsPanel extends JPanel { intoWind.addEnableComponent(directionSlider, false); } + public static void addSimulationConditionsPanel(JPanel parent, SimulationOptionsInterface target) { + addSimulationConditionsPanel(parent, target, true); + } + + private static void addWindModelPanel(JPanel panel, SimulationOptionsInterface target) { + ButtonGroup windModelGroup = new ButtonGroup(); + + panel.add(new JLabel(trans.get("simedtdlg.lbl.WindModelSelection")), "spanx, split 3, gapright para"); + + JRadioButton pinkNoiseButton = new JRadioButton(trans.get("simedtdlg.radio.PinkNoiseWind")); + pinkNoiseButton.setToolTipText(trans.get("simedtdlg.radio.PinkNoiseWind.ttip")); + JRadioButton multiLevelButton = new JRadioButton(trans.get("simedtdlg.radio.MultiLevelWind")); + multiLevelButton.setToolTipText(trans.get("simedtdlg.radio.MultiLevelWind.ttip")); + + windModelGroup.add(pinkNoiseButton); + windModelGroup.add(multiLevelButton); + + panel.add(pinkNoiseButton); + panel.add(multiLevelButton, "wrap"); + + panel.add(new JSeparator(JSeparator.HORIZONTAL), "spanx, growx, wrap"); + + JPanel windSettingsPanel = new JPanel(new CardLayout()); + + JPanel pinkNoisePanel = new JPanel(new MigLayout("fill, ins 0, gap rel unrel", "[grow][75lp!][30lp!][75lp!]", "")); + JPanel multiLevelPanel = new JPanel(new MigLayout("fill, ins 0, gap rel unrel", "[grow]", "")); + + addPinkNoiseSettings(pinkNoisePanel, target); + addMultiLevelSettings(multiLevelPanel, target); + + windSettingsPanel.add(pinkNoisePanel, "PinkNoise"); + windSettingsPanel.add(multiLevelPanel, "MultiLevel"); + + panel.add(windSettingsPanel, "grow, wrap"); + + pinkNoiseButton.addActionListener(e -> { + ((CardLayout) windSettingsPanel.getLayout()).show(windSettingsPanel, "PinkNoise"); + if (target instanceof SimulationOptions) { + ((SimulationOptions) target).setWindModelType(WindModelType.PINK_NOISE); + } + }); + + multiLevelButton.addActionListener(e -> { + ((CardLayout) windSettingsPanel.getLayout()).show(windSettingsPanel, "MultiLevel"); + if (target instanceof SimulationOptions) { + ((SimulationOptions) target).setWindModelType(WindModelType.MULTI_LEVEL); + } + }); + + // Set initial selection based on current wind model + if (target instanceof SimulationOptions) { + SimulationOptions options = (SimulationOptions) target; + if (options.getWindModelType() == WindModelType.PINK_NOISE) { + pinkNoiseButton.setSelected(true); + ((CardLayout) windSettingsPanel.getLayout()).show(windSettingsPanel, "PinkNoise"); + } else { + multiLevelButton.setSelected(true); + ((CardLayout) windSettingsPanel.getLayout()).show(windSettingsPanel, "MultiLevel"); + } + } + } + + private static void addPinkNoiseSettings(JPanel panel, SimulationOptionsInterface target) { + PinkNoiseWindModel model = target.getPinkNoiseWindModel(); + + // Wind average + final DoubleModel windSpeedAverage = addDoubleModel(panel, "Averwindspeed", trans.get("simedtdlg.lbl.ttip.Averwindspeed"), model, "Average", + UnitGroup.UNITS_WINDSPEED, 0, 10.0); + + // Wind standard deviation + final DoubleModel windSpeedDeviation = addDoubleModel(panel, "Stddeviation", trans.get("simedtdlg.lbl.ttip.Stddeviation"), + model, "StandardDeviation", UnitGroup.UNITS_WINDSPEED, 0, + new DoubleModel(model, "Average", 0.25, UnitGroup.UNITS_COEFFICIENT, 0)); + + windSpeedAverage.addChangeListener(new ChangeListener() { + @Override + public void stateChanged(ChangeEvent e) { + windSpeedDeviation.stateChanged(e); + } + }); + + // Turbulence intensity + String tip = trans.get("simedtdlg.lbl.ttip.Turbulenceintensity1") + + trans.get("simedtdlg.lbl.ttip.Turbulenceintensity2") + " " + + UnitGroup.UNITS_RELATIVE.getDefaultUnit().toStringUnit(0.05) + + " " + trans.get("simedtdlg.lbl.ttip.Turbulenceintensity3") + " " + + UnitGroup.UNITS_RELATIVE.getDefaultUnit().toStringUnit(0.20) + "."; + final DoubleModel windTurbulenceIntensity = addDoubleModel(panel, "Turbulenceintensity", tip, model, + "TurbulenceIntensity", UnitGroup.UNITS_RELATIVE, 0, 1.0, true); + + final JLabel intensityLabel = new JLabel( + getIntensityDescription(target.getPinkNoiseWindModel().getTurbulenceIntensity())); + intensityLabel.setToolTipText(tip); + panel.add(intensityLabel, "w 75lp, skip 1, wrap"); + windTurbulenceIntensity.addChangeListener(new ChangeListener() { + @Override + public void stateChanged(ChangeEvent e) { + intensityLabel.setText( + getIntensityDescription(target.getPinkNoiseWindModel().getTurbulenceIntensity())); + windSpeedDeviation.stateChanged(e); + } + }); + windSpeedDeviation.addChangeListener(new ChangeListener() { + @Override + public void stateChanged(ChangeEvent e) { + windTurbulenceIntensity.stateChanged(e); + } + }); + + // Wind direction + addDoubleModel(panel, "Winddirection", trans.get("simedtdlg.lbl.ttip.Winddirection"), model, "Direction", + UnitGroup.UNITS_ANGLE, 0, 2 * Math.PI); + } + + private static void addMultiLevelSettings(JPanel panel, SimulationOptionsInterface target) { + if (!(target instanceof SimulationOptions options)) { + return; + } + MultiLevelWindModel model = options.getMultiLevelWindModel(); + + // Create the levels table + WindLevelTableModel tableModel = new WindLevelTableModel(model); + JTable windLevelTable = new JTable(tableModel); + windLevelTable.setRowSelectionAllowed(false); + windLevelTable.setColumnSelectionAllowed(false); + windLevelTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + + // Set up value columns + SelectAllCellEditor selectAllEditor = new SelectAllCellEditor(); + ValueCellRenderer valueCellRenderer = new ValueCellRenderer(); + for (int i = 0; i < windLevelTable.getColumnCount(); i += 2) { + windLevelTable.getColumnModel().getColumn(i).setCellRenderer(valueCellRenderer); + windLevelTable.getColumnModel().getColumn(i).setCellEditor(selectAllEditor); + } + + // Set up unit selector columns + for (int i = 1; i < windLevelTable.getColumnCount(); i += 2) { + windLevelTable.getColumnModel().getColumn(i).setCellRenderer(new UnitSelectorRenderer()); + windLevelTable.getColumnModel().getColumn(i).setCellEditor(new UnitSelectorEditor()); + } + + // Adjust column widths + adjustColumnWidths(windLevelTable); + + windLevelTable.setRowHeight(windLevelTable.getRowHeight() + 10); + + // Set up sorting + TableRowSorter sorter = new TableRowSorter<>(tableModel); + windLevelTable.setRowSorter(sorter); + sorter.setSortable(0, true); + sorter.setSortable(1, false); + sorter.setSortable(2, false); + sorter.setComparator(0, Comparator.comparingDouble(a -> (Double) a)); + sorter.setSortKeys(List.of(new RowSorter.SortKey(0, SortOrder.ASCENDING))); + + JScrollPane scrollPane = new JScrollPane(windLevelTable); + scrollPane.setPreferredSize(new Dimension(350, 150)); + panel.add(scrollPane, "grow"); + + //// Buttons + JPanel buttonPanel = new JPanel(new MigLayout("fill, ins 0, gap rel unrel", "[grow]", "")); + + // Add wind level + JButton addButton = new JButton(trans.get("simedtdlg.but.addWindLevel")); + addButton.addActionListener(e -> { + tableModel.addWindLevel(); + sorter.sort(); + }); + buttonPanel.add(addButton, "growx, wrap"); + + // Remove wind level + JButton removeButton = new JButton(trans.get("simedtdlg.but.removeWindLevel")); + removeButton.addActionListener(e -> { + int selectedRow = windLevelTable.getSelectedRow(); + tableModel.removeWindLevel(selectedRow); + sorter.sort(); + }); + buttonPanel.add(removeButton, "growx, wrap"); + + // Visualization button + JButton visualizeButton = new JButton(trans.get("simedtdlg.but.visualizeWindLevels")); + visualizeButton.addActionListener(e -> { + Window owner = SwingUtilities.getWindowAncestor(panel); + if (owner instanceof Dialog) { + WindLevelVisualizationDialog visualizationDialog = new WindLevelVisualizationDialog( + (Dialog) owner, + model, + tableModel.getCurrentUnits()[0], + tableModel.getCurrentUnits()[1] + ); + tableModel.setVisualizationDialog(visualizationDialog); + visualizationDialog.setVisible(true); + } + }); + buttonPanel.add(visualizeButton, "growx, wrap"); + + panel.add(buttonPanel, "grow, wrap"); + + // Add listener to update visualization when table data changes + tableModel.addTableModelListener(e -> { + sorter.sort(); + if (tableModel.getVisualizationDialog() != null) { + tableModel.getVisualizationDialog().repaint(); + } + }); + } + + private static DoubleModel addDoubleModel(JPanel panel, String labelKey, String tooltipText, Object source, String sourceKey, + UnitGroup unit, double min, Object max, boolean easterEgg) { + JLabel label = new JLabel(trans.get("simedtdlg.lbl." + labelKey)); + panel.add(label); + + DoubleModel model; + if (max instanceof Double) { + model = new DoubleModel(source, sourceKey, unit, min, (Double) max); + } else if (max instanceof DoubleModel) { + model = new DoubleModel(source, sourceKey, unit, min, (DoubleModel) max); + } else { + throw new IllegalArgumentException("Invalid max value"); + } + + JSpinner spin = new JSpinner(model.getSpinnerModel()); + spin.setEditor(new SpinnerEditor(spin)); + if (tooltipText != null) { + spin.setToolTipText(tooltipText); + } + panel.add(spin, "growx"); + + if (easterEgg) { + addEasterEgg(spin, panel); + } + + UnitSelector unitSelector = new UnitSelector(model); + panel.add(unitSelector, "growx"); + + BasicSlider slider = new BasicSlider(model.getSliderModel()); + panel.add(slider, "w 75lp, wrap"); + + return model; + } + + private static DoubleModel addDoubleModel(JPanel panel, String labelKey, String tooltipText, Object source, String sourceKey, + UnitGroup unit, double min, Object max) { + return addDoubleModel(panel, labelKey, tooltipText, source, sourceKey, unit, min, max, false); + } + + private static void adjustColumnWidths(JTable table) { + TableColumnModel columnModel = table.getColumnModel(); + for (int column = 0; column < table.getColumnCount(); column++) { + TableColumn tableColumn = columnModel.getColumn(column); + int preferredWidth = getPreferredColumnWidth(table, column); + tableColumn.setPreferredWidth(preferredWidth); + } + } + + private static int getPreferredColumnWidth(JTable table, int column) { + TableColumn tableColumn = table.getColumnModel().getColumn(column); + + // Get width of column header + TableCellRenderer headerRenderer = tableColumn.getHeaderRenderer(); + if (headerRenderer == null) { + headerRenderer = table.getTableHeader().getDefaultRenderer(); + } + Object headerValue = tableColumn.getHeaderValue(); + Component headerComp = headerRenderer.getTableCellRendererComponent(table, headerValue, false, false, 0, column); + int headerWidth = headerComp.getPreferredSize().width; + + // Get maximum width of column data + int maxWidth = headerWidth; + for (int row = 0; row < table.getRowCount(); row++) { + TableCellRenderer cellRenderer = table.getCellRenderer(row, column); + Component comp = table.prepareRenderer(cellRenderer, row, column); + maxWidth = Math.max(maxWidth, comp.getPreferredSize().width); + } + + // Add some padding + return maxWidth + 10; + } + + private void addDefaultButtons(SimulationOptions options) { + JButton restoreDefaults = new JButton(trans.get("simedtdlg.but.resettodefault")); + restoreDefaults.addActionListener(e -> { + DefaultSimulationOptionFactory f = Application.getInjector().getInstance(DefaultSimulationOptionFactory.class); + SimulationOptions defaults = f.getDefault(); + options.copyConditionsFrom(defaults); + }); + this.add(restoreDefaults, "span, split 3, skip, gapbottom para, gapright para, right"); + + JButton saveDefaults = new JButton(trans.get("simedtdlg.but.savedefault")); + saveDefaults.addActionListener(e -> { + DefaultSimulationOptionFactory f = Application.getInjector().getInstance(DefaultSimulationOptionFactory.class); + f.saveDefault(options); + }); + this.add(saveDefaults, "gapbottom para, gapright para, right"); + } + private static String getIntensityDescription(double i) { if (i < 0.001) //// None @@ -524,4 +704,241 @@ public class SimulationConditionsPanel extends JPanel { } }); } + + public void cleanup() { + // Dispose of the visualization dialog if it exists + if (visualizationDialog != null) { + visualizationDialog.dispose(); + visualizationDialog = null; + } + + // Remove all components from the panel + removeAll(); + } + + @Override + public void addNotify() { + super.addNotify(); + + // Now that the panel is added to a container, we can safely get the parent window + Window parent = SwingUtilities.getWindowAncestor(this); + if (parent != null) { + parent.addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + cleanup(); + } + }); + } + } + + private static class WindLevelTableModel extends AbstractTableModel { + private final MultiLevelWindModel model; + private static final String[] columnNames = { + trans.get("simedtdlg.col.Altitude"), + trans.get("simedtdlg.col.Unit"), + trans.get("simedtdlg.col.Speed"), + trans.get("simedtdlg.col.Unit"), + trans.get("simedtdlg.col.Direction"), + trans.get("simedtdlg.col.Unit"), + }; + private static final UnitGroup[] unitGroups = { + UnitGroup.UNITS_DISTANCE, UnitGroup.UNITS_VELOCITY, UnitGroup.UNITS_ANGLE}; + private final Unit[] currentUnits = { + UnitGroup.UNITS_DISTANCE.getDefaultUnit(), + UnitGroup.UNITS_VELOCITY.getDefaultUnit(), + UnitGroup.UNITS_ANGLE.getDefaultUnit() + }; + private WindLevelVisualizationDialog visualizationDialog; + + public WindLevelTableModel(MultiLevelWindModel model) { + this.model = model; + } + + public void setVisualizationDialog(WindLevelVisualizationDialog visualizationDialog) { + this.visualizationDialog = visualizationDialog; + } + + public WindLevelVisualizationDialog getVisualizationDialog() { + return visualizationDialog; + } + + @Override + public int getRowCount() { + return model.getLevels().size(); + } + + @Override + public int getColumnCount() { + return columnNames.length; + } + + @Override + public String getColumnName(int column) { + return columnNames[column]; + } + + @Override + public Class getColumnClass(int columnIndex) { + return (columnIndex % 2 == 0) ? Double.class : Unit.class; + } + + public Object getSIValueAt(int rowIndex, int columnIndex) { + MultiLevelWindModel.WindLevel level = model.getLevels().get(rowIndex); + return switch (columnIndex) { + case 0 -> level.altitude; + case 2 -> level.speed; + case 4 -> level.direction; + default -> null; + }; + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) { + if (columnIndex % 2 == 0) { + Object rawValue = getSIValueAt(rowIndex, columnIndex); + if (rawValue == null) { + return null; + } + return currentUnits[columnIndex / 2].toUnit((double) rawValue); + } else { + return currentUnits[columnIndex / 2]; + } + } + + public Unit[] getCurrentUnits() { + return currentUnits; + } + + @Override + public void setValueAt(Object aValue, int rowIndex, int columnIndex) { + MultiLevelWindModel.WindLevel level = model.getLevels().get(rowIndex); + if (columnIndex % 2 == 0) { + // Value column + double value = Double.parseDouble((String) aValue); + switch (columnIndex) { + case 0: + level.altitude = currentUnits[0].fromUnit(value); + break; + case 2: + // Handle negative speed + if (value < 0) { + level.speed = currentUnits[1].fromUnit(Math.abs(value)); + // Adjust direction by 180 degrees + level.direction = (level.direction + Math.PI) % (2 * Math.PI); + } else { + level.speed = currentUnits[1].fromUnit(value); + } + break; + case 4: + level.direction = currentUnits[2].fromUnit(value); + break; + } + } else { + // Unit column + Unit unit = (Unit) aValue; + currentUnits[columnIndex / 2] = unit; + if (visualizationDialog != null) { + visualizationDialog.updateUnits(currentUnits[0], currentUnits[1]); + } + } + fireTableDataChanged(); + } + + @Override + public boolean isCellEditable(int rowIndex, int columnIndex) { + return true; + } + + public void addWindLevel() { + List levels = model.getLevels(); + double newAltitude = levels.isEmpty() ? 0 : levels.get(levels.size() - 1).altitude + 100; + double newSpeed = levels.isEmpty() ? 5 : levels.get(levels.size() - 1).speed; + double newDirection = levels.isEmpty() ? Math.PI / 2 : levels.get(levels.size() - 1).direction; + + model.addWindLevel(newAltitude, newSpeed, newDirection); + fireTableDataChanged(); + } + + public void removeWindLevel(int index) { + if (index >= 0 && index < model.getLevels().size()) { + model.removeWindLevelIdx(index); + fireTableDataChanged(); + } + } + + public UnitGroup getUnitGroup(int columnIndex) { + return unitGroups[columnIndex / 2]; + } + } + + private static class UnitSelectorRenderer extends DefaultTableCellRenderer { + @Override + public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) { + Unit unit = (Unit) value; + return super.getTableCellRendererComponent(table, unit.getUnit(), isSelected, hasFocus, row, column); + } + } + + private static class UnitSelectorEditor extends DefaultCellEditor { + private final JComboBox comboBox; + + public UnitSelectorEditor() { + super(new JComboBox<>()); + comboBox = (JComboBox) getComponent(); + } + + @Override + public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) { + WindLevelTableModel model = (WindLevelTableModel) table.getModel(); + UnitGroup unitGroup = model.getUnitGroup(column); + comboBox.removeAllItems(); + for (Unit unit : unitGroup.getUnits()) { + comboBox.addItem(unit); + } + comboBox.setSelectedItem(value); + return comboBox; + } + } + + private static class ValueCellRenderer extends DefaultTableCellRenderer { + @Override + public Component getTableCellRendererComponent(JTable table, Object value, + boolean isSelected, boolean hasFocus, int row, int column) { + if (value instanceof Double) { + WindLevelTableModel model = (WindLevelTableModel) table.getModel(); + Unit unit = model.getCurrentUnits()[column / 2]; + double SIValue = unit.fromUnit((Double) value); + value = unit.toString(SIValue); + } + return super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column); + } + } + + private static class SelectAllCellEditor extends DefaultCellEditor { + public SelectAllCellEditor() { + super(new JTextField()); + setClickCountToStart(1); + } + + @Override + public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) { + JTextField textField = (JTextField) super.getTableCellEditorComponent(table, value, isSelected, row, column); + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + textField.selectAll(); + } + }); + return textField; + } + + @Override + public boolean isCellEditable(EventObject e) { + if (e instanceof MouseEvent) { + return ((MouseEvent) e).getClickCount() >= getClickCountToStart(); + } + return super.isCellEditable(e); + } + } } diff --git a/swing/src/main/java/info/openrocket/swing/gui/simulation/WindLevelVisualizationDialog.java b/swing/src/main/java/info/openrocket/swing/gui/simulation/WindLevelVisualizationDialog.java new file mode 100644 index 000000000..ca5f6c1a9 --- /dev/null +++ b/swing/src/main/java/info/openrocket/swing/gui/simulation/WindLevelVisualizationDialog.java @@ -0,0 +1,244 @@ +package info.openrocket.swing.gui.simulation; + +import info.openrocket.core.l10n.Translator; +import info.openrocket.core.models.wind.MultiLevelWindModel; +import info.openrocket.core.startup.Application; +import info.openrocket.core.unit.Unit; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JDialog; +import javax.swing.JPanel; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dialog; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.event.WindowListener; +import java.awt.geom.AffineTransform; +import java.util.Comparator; +import java.util.List; + +public class WindLevelVisualizationDialog extends JDialog { + private static final Translator trans = Application.getTranslator(); + + private final WindLevelVisualization visualization; + private final JCheckBox showDirectionsCheckBox; + + public WindLevelVisualizationDialog(Dialog owner, MultiLevelWindModel model, Unit altitudeUnit, Unit speedUnit) { + super(owner, trans.get("WindLevelVisualizationDialog.title.WindLevelVisualization"), false); + + visualization = new WindLevelVisualization(model, altitudeUnit, speedUnit); + visualization.setPreferredSize(new Dimension(400, 500)); + + JPanel contentPane = new JPanel(new BorderLayout()); + contentPane.add(visualization, BorderLayout.CENTER); + + // Use BorderLayout for the control panel + JPanel controlPanel = new JPanel(new BorderLayout()); + controlPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5)); // Add some padding + + // Checkbox on the left + showDirectionsCheckBox = new JCheckBox(trans.get("WindLevelVisualizationDialog.checkbox.ShowDirections")); + showDirectionsCheckBox.setSelected(true); + showDirectionsCheckBox.addActionListener(e -> { + visualization.setShowDirections(showDirectionsCheckBox.isSelected()); + visualization.repaint(); + }); + controlPanel.add(showDirectionsCheckBox, BorderLayout.WEST); + + // Close button on the right + JButton closeButton = new JButton(trans.get("button.close")); + closeButton.addActionListener(e -> dispose()); + JPanel closeButtonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0)); + closeButtonPanel.add(closeButton); + controlPanel.add(closeButtonPanel, BorderLayout.EAST); + + contentPane.add(controlPanel, BorderLayout.SOUTH); + + setContentPane(contentPane); + pack(); + setLocationRelativeTo(owner); + + setDefaultCloseOperation(HIDE_ON_CLOSE); + setAlwaysOnTop(true); + } + + public void updateUnits(Unit altitudeUnit, Unit speedUnit) { + visualization.updateUnits(altitudeUnit, speedUnit); + } + + private static class WindLevelVisualization extends JPanel { + private final MultiLevelWindModel model; + private static final int MARGIN = 50; + private static final int ARROW_SIZE = 10; + private static final int TICK_LENGTH = 5; + + private Unit altitudeUnit; + private Unit speedUnit; + private boolean showDirections = true; + + public WindLevelVisualization(MultiLevelWindModel model, Unit altitudeUnit, Unit speedUnit) { + this.model = model; + this.altitudeUnit = altitudeUnit; + this.speedUnit = speedUnit; + } + + public void updateUnits(Unit altitudeUnit, Unit speedUnit) { + this.altitudeUnit = altitudeUnit; + this.speedUnit = speedUnit; + repaint(); + } + + public void setShowDirections(boolean showDirections) { + this.showDirections = showDirections; + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2d = (Graphics2D) g; + int width = getWidth(); + int height = getHeight(); + + // Enable antialiasing for smoother lines + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + // Draw background + g2d.setColor(Color.WHITE); + g2d.fillRect(0, 0, width, height); + + List levels = model.getLevels(); + if (levels.isEmpty()) return; + + // Sort levels before drawing + levels.sort(Comparator.comparingDouble(level -> level.altitude)); + + double maxAltitude = levels.stream().mapToDouble(l -> l.altitude).max().orElse(1000); + double maxSpeed = levels.stream().mapToDouble(l -> l.speed).max().orElse(10); + + // Extend axis ranges by 10% for drawing + double extendedMaxAltitude = maxAltitude * 1.1; + double extendedMaxSpeed = maxSpeed * 1.1; + + // Draw axes + drawAxes(g2d, width, height, maxSpeed, maxAltitude, extendedMaxSpeed, extendedMaxAltitude); + + // Draw wind levels + for (int i = 0; i < levels.size(); i++) { + MultiLevelWindModel.WindLevel level = levels.get(i); + + int x = MARGIN + (int) (level.speed / extendedMaxSpeed * (width - 2 * MARGIN)); + int y = height - MARGIN - (int) (level.altitude / extendedMaxAltitude * (height - 2 * MARGIN)); + + // Draw point + g2d.setColor(Color.BLUE); + g2d.fillOval(x - 3, y - 3, 6, 6); + + // Draw wind direction arrow + if (showDirections) { + drawWindArrow(g2d, x, y, level.direction); + } + + // Draw connecting line if not the first point + if (i > 0) { + MultiLevelWindModel.WindLevel prevLevel = levels.get(i - 1); + int prevX = MARGIN + (int) (prevLevel.speed / extendedMaxSpeed * (width - 2 * MARGIN)); + int prevY = height - MARGIN - (int) (prevLevel.altitude / extendedMaxAltitude * (height - 2 * MARGIN)); + g2d.setColor(Color.GRAY); + g2d.drawLine(prevX, prevY, x, y); + } + } + } + + private void drawAxes(Graphics2D g2d, int width, int height, double maxSpeed, double maxAltitude, + double extendedMaxSpeed, double extendedMaxAltitude) { + g2d.setColor(Color.BLACK); + + // Draw X-axis + g2d.drawLine(MARGIN, height - MARGIN, width - MARGIN, height - MARGIN); + drawFilledArrowHead(g2d, width - MARGIN + (ARROW_SIZE-2), height - MARGIN, ARROW_SIZE, 0); + + // Draw Y-axis + g2d.drawLine(MARGIN, height - MARGIN, MARGIN, MARGIN); + drawFilledArrowHead(g2d, MARGIN, MARGIN - (ARROW_SIZE-2), ARROW_SIZE, -Math.PI / 2); + + // Draw max value ticks and labels + g2d.setFont(g2d.getFont().deriveFont(10f)); + FontMetrics fm = g2d.getFontMetrics(); + + // X-axis max value + int xTickX = MARGIN + (int) ((maxSpeed / extendedMaxSpeed) * (width - 2 * MARGIN)); + g2d.drawLine(xTickX, height - MARGIN, xTickX, height - MARGIN + TICK_LENGTH); + String xMaxLabel = speedUnit.toString(maxSpeed); + g2d.drawString(xMaxLabel, xTickX - fm.stringWidth(xMaxLabel) / 2, height - MARGIN + TICK_LENGTH + fm.getHeight()); + + // Y-axis max value + int yTickY = height - MARGIN - (int) ((maxAltitude / extendedMaxAltitude) * (height - 2 * MARGIN)); + g2d.drawLine(MARGIN - TICK_LENGTH, yTickY, MARGIN, yTickY); + String yMaxLabel = altitudeUnit.toString(maxAltitude); + g2d.drawString(yMaxLabel, MARGIN - TICK_LENGTH - fm.stringWidth(yMaxLabel) - 2, yTickY + fm.getAscent() / 2); + + // Draw axis labels + g2d.setFont(g2d.getFont().deriveFont(12f)); + fm = g2d.getFontMetrics(); + + // X-axis label + String xLabel = trans.get("WindLevelVisualizationDialog.lbl.WindSpeed") + " (" + speedUnit.getUnit() + ")"; + g2d.drawString(xLabel, width / 2 - fm.stringWidth(xLabel) / 2, height - 10); + + // Y-axis label + String yLabel = trans.get("WindLevelVisualizationDialog.lbl.Altitude") + " (" + altitudeUnit.getUnit() + ")"; + AffineTransform originalTransform = g2d.getTransform(); + g2d.rotate(-Math.PI / 2); + g2d.drawString(yLabel, -height / 2 - fm.stringWidth(yLabel) / 2, MARGIN / 2); + g2d.setTransform(originalTransform); + } + + private void drawFilledArrowHead(Graphics2D g, int x, int y, int arrowSize, double angle) { + int[] xPoints = new int[3]; + int[] yPoints = new int[3]; + + xPoints[0] = x; + yPoints[0] = y; + xPoints[1] = x - (int) (arrowSize * Math.cos(angle - Math.PI / 6)); + yPoints[1] = y - (int) (arrowSize * Math.sin(angle - Math.PI / 6)); + xPoints[2] = x - (int) (arrowSize * Math.cos(angle + Math.PI / 6)); + yPoints[2] = y - (int) (arrowSize * Math.sin(angle + Math.PI / 6)); + + g.fillPolygon(xPoints, yPoints, 3); + } + + private void drawWindArrow(Graphics2D g, int x, int y, double direction) { + int directionVectorLength = 15; + int dx = (int) (directionVectorLength * Math.sin(direction)); + int dy = (int) (directionVectorLength * Math.cos(direction)); + int arrowSize = 10; + + g.setColor(Color.RED); + + // Draw the main line + g.drawLine(x, y, x + dx, y - dy); + + int dx_arrow = (int) ((arrowSize-1) * Math.sin(direction)); + int dy_arrow = (int) ((arrowSize-1) * Math.cos(direction)); + + // Draw filled arrow head + drawFilledArrowHead(g, x + dx + dx_arrow, y - dy - dy_arrow, arrowSize, direction - Math.PI/2); + } + } + + @Override + public void dispose() { + for (WindowListener listener : getWindowListeners()) { + removeWindowListener(listener); + } + + super.dispose(); + } +} diff --git a/swing/src/main/java/info/openrocket/swing/gui/util/SwingPreferences.java b/swing/src/main/java/info/openrocket/swing/gui/util/SwingPreferences.java index 0d404be9a..89cda70f9 100644 --- a/swing/src/main/java/info/openrocket/swing/gui/util/SwingPreferences.java +++ b/swing/src/main/java/info/openrocket/swing/gui/util/SwingPreferences.java @@ -40,7 +40,6 @@ import info.openrocket.core.rocketcomponent.RailButton; import info.openrocket.core.rocketcomponent.RecoveryDevice; import info.openrocket.core.rocketcomponent.RocketComponent; import info.openrocket.core.rocketcomponent.TubeFinSet; -import info.openrocket.core.simulation.SimulationOptionsInterface; import info.openrocket.core.util.ORColor; import info.openrocket.core.arch.SystemInfo; import info.openrocket.core.document.Simulation; @@ -59,7 +58,7 @@ import info.openrocket.core.util.BuildProperties; import info.openrocket.swing.communication.AssetHandler.UpdatePlatform; -public class SwingPreferences extends ApplicationPreferences implements SimulationOptionsInterface { +public class SwingPreferences extends ApplicationPreferences { private static final Logger log = LoggerFactory.getLogger(SwingPreferences.class);