package net.sf.openrocket.document; import java.io.File; import java.util.Collection; import java.util.Collections; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.Set; import net.sf.openrocket.appearance.Appearance; import net.sf.openrocket.appearance.Decal; import net.sf.openrocket.appearance.DecalImage; import net.sf.openrocket.document.events.DocumentChangeEvent; import net.sf.openrocket.document.events.DocumentChangeListener; import net.sf.openrocket.document.events.SimulationChangeEvent; import net.sf.openrocket.logging.Markers; import net.sf.openrocket.rocketcomponent.ComponentChangeEvent; import net.sf.openrocket.rocketcomponent.ComponentChangeListener; import net.sf.openrocket.rocketcomponent.Configuration; import net.sf.openrocket.rocketcomponent.Rocket; import net.sf.openrocket.rocketcomponent.RocketComponent; import net.sf.openrocket.simulation.FlightDataType; import net.sf.openrocket.simulation.customexpression.CustomExpression; import net.sf.openrocket.simulation.extension.SimulationExtension; import net.sf.openrocket.startup.Application; import net.sf.openrocket.util.ArrayList; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Class describing an entire OpenRocket document, including a rocket and * simulations. The document contains: * <p> * - the rocket definition * - a default Configuration * - Simulation instances * - the stored file and file save information * - undo/redo information * * @author Sampo Niskanen <sampo.niskanen@iki.fi> */ public class OpenRocketDocument implements ComponentChangeListener { private static final Logger log = LoggerFactory.getLogger(OpenRocketDocument.class); /** * The minimum number of undo levels that are stored. */ public static final int UNDO_LEVELS = 50; /** * The margin of the undo levels. After the number of undo levels exceeds * UNDO_LEVELS by this amount the undo is purged to that length. */ public static final int UNDO_MARGIN = 10; public static final String SIMULATION_NAME_PREFIX = "Simulation "; /** Whether an undo error has already been reported to the user */ private static boolean undoErrorReported = false; private final Rocket rocket; private final Configuration configuration; private final ArrayList<Simulation> simulations = new ArrayList<Simulation>(); private ArrayList<CustomExpression> customExpressions = new ArrayList<CustomExpression>(); /* * The undo/redo variables and mechanism are documented in doc/undo-redo-flow.* */ /** * The undo history of the rocket. Whenever a new undo position is created while the * rocket is in "dirty" state, the rocket is copied here. */ private LinkedList<Rocket> undoHistory = new LinkedList<Rocket>(); private LinkedList<String> undoDescription = new LinkedList<String>(); /** * The position in the undoHistory we are currently at. If modifications have been * made to the rocket, the rocket is in "dirty" state and this points to the previous * "clean" state. */ private int undoPosition = -1; // Illegal position, init in constructor /** * The description of the next action that modifies this rocket. */ private String nextDescription = null; private String storedDescription = null; private ArrayList<UndoRedoListener> undoRedoListeners = new ArrayList<UndoRedoListener>(2); private File file = null; private int savedID = -1; private final StorageOptions storageOptions = new StorageOptions(); private final DecalRegistry decalRegistry = new DecalRegistry(); private final List<DocumentChangeListener> listeners = new ArrayList<DocumentChangeListener>(); OpenRocketDocument(Rocket rocket) { this.configuration = rocket.getDefaultConfiguration(); this.rocket = rocket; init(); } private void init() { clearUndo(); rocket.addComponentChangeListener(this); } public void addCustomExpression(CustomExpression expression) { if (customExpressions.contains(expression)) { log.info(Markers.USER_MARKER, "Could not add custom expression " + expression.getName() + " to document as document alerady has a matching expression."); } else { customExpressions.add(expression); } } public void removeCustomExpression(CustomExpression expression) { customExpressions.remove(expression); } public List<CustomExpression> getCustomExpressions() { return customExpressions; } /* * Returns a set of all the flight data types defined or available in any way in the rocket document */ public Set<FlightDataType> getFlightDataTypes() { Set<FlightDataType> allTypes = new LinkedHashSet<FlightDataType>(); // built in Collections.addAll(allTypes, FlightDataType.ALL_TYPES); // custom expressions for (CustomExpression exp : customExpressions) { allTypes.add(exp.getType()); } // simulation listeners for (Simulation sim : simulations) { for (SimulationExtension c : sim.getSimulationExtensions()) { allTypes.addAll(c.getFlightDataTypes()); } } // imported data /// not implemented yet return allTypes; } public Rocket getRocket() { return rocket; } public Configuration getDefaultConfiguration() { return configuration; } public File getFile() { return file; } public void setFile(File file) { this.file = file; } public boolean isSaved() { return rocket.getModID() == savedID; } public void setSaved(boolean saved) { if (saved == false) this.savedID = -1; else this.savedID = rocket.getModID(); } /** * Retrieve the default storage options for this document. * * @return the storage options. */ public StorageOptions getDefaultStorageOptions() { return storageOptions; } public Collection<DecalImage> getDecalList() { return decalRegistry.getDecalList(); } public int countDecalUsage(DecalImage img) { int count = 0; Iterator<RocketComponent> it = rocket.iterator(); while (it.hasNext()) { RocketComponent comp = it.next(); Appearance a = comp.getAppearance(); if (a == null) { continue; } Decal d = a.getTexture(); if (d == null) { continue; } if (img.equals(d.getImage())) { count++; } } return count; } public DecalImage makeUniqueDecal(DecalImage img) { if (countDecalUsage(img) <= 1) { return img; } return decalRegistry.makeUniqueImage(img); } public DecalImage getDecalImage(Attachment a) { return decalRegistry.getDecalImage(a); } public List<Simulation> getSimulations() { return simulations.clone(); } public int getSimulationCount() { return simulations.size(); } public Simulation getSimulation(int n) { return simulations.get(n); } public int getSimulationIndex(Simulation simulation) { return simulations.indexOf(simulation); } public void addSimulation(Simulation simulation) { simulations.add(simulation); fireDocumentChangeEvent(new SimulationChangeEvent(simulation)); } public void addSimulation(Simulation simulation, int n) { simulations.add(n, simulation); fireDocumentChangeEvent(new SimulationChangeEvent(simulation)); } public void removeSimulation(Simulation simulation) { simulations.remove(simulation); fireDocumentChangeEvent(new SimulationChangeEvent(simulation)); } public Simulation removeSimulation(int n) { Simulation simulation = simulations.remove(n); fireDocumentChangeEvent(new SimulationChangeEvent(simulation)); return simulation; } public void removeFlightConfigurationAndSimulations(String configId) { if (configId == null) { return; } for (Simulation s : getSimulations()) { // Assumes modifiable collection - which it is if (configId.equals(s.getConfiguration().getFlightConfigurationID())) { removeSimulation(s); } } rocket.removeFlightConfigurationID(configId); } /** * Return a unique name suitable for the next simulation. The name begins * with {@link #SIMULATION_NAME_PREFIX} and has a unique number larger than any * previous simulation. * * @return the new name. */ public String getNextSimulationName() { // Generate unique name for the simulation int maxValue = 0; for (Simulation s : simulations) { String name = s.getName(); if (name.startsWith(SIMULATION_NAME_PREFIX)) { try { maxValue = Math.max(maxValue, Integer.parseInt(name.substring(SIMULATION_NAME_PREFIX.length()))); } catch (NumberFormatException ignore) { } } } return SIMULATION_NAME_PREFIX + (maxValue + 1); } /** * Adds an undo point at this position. This method should be called *before* any * action that is to be undoable. All actions after the call will be undone by a * single "Undo" action. * <p> * The description should be a short, descriptive string of the actions that will * follow. This is shown to the user e.g. in the Edit-menu, for example * "Undo (Modify body tube)". If the actions are not known (in general should not * be the case!) description may be null. * <p> * If this method is called successively without any change events occurring between the * calls, only the last call will have any effect. * * @param description A short description of the following actions. */ public void addUndoPosition(String description) { if (storedDescription != null) { logUndoError("addUndoPosition called while storedDescription=" + storedDescription + " description=" + description); } // Check whether modifications have been done since last call if (isCleanState()) { // No modifications log.info("Adding undo position '" + description + "' to " + this + ", document was in clean state"); nextDescription = description; return; } log.info("Adding undo position '" + description + "' to " + this + ", document is in unclean state"); /* * Modifications have been made to the rocket. We should be at the end of the * undo history, but check for consistency and try to recover. */ if (undoPosition != undoHistory.size() - 1) { logUndoError("undo position inconsistency"); } while (undoPosition < undoHistory.size() - 1) { undoHistory.removeLast(); undoDescription.removeLast(); } // Add the current state to the undo history undoHistory.add(rocket.copyWithOriginalID()); undoDescription.add(null); nextDescription = description; undoPosition++; // Maintain maximum undo size if (undoHistory.size() > UNDO_LEVELS + UNDO_MARGIN && undoPosition > UNDO_MARGIN) { for (int i = 0; i < UNDO_MARGIN; i++) { undoHistory.removeFirst(); undoDescription.removeFirst(); undoPosition--; } } } /** * Start a time-limited undoable operation. After the operation {@link #stopUndo()} * must be called, which will restore the previous undo description into effect. * Only one level of start-stop undo descriptions is supported, i.e. start-stop * undo cannot be nested, and no other undo operations may be called between * the start and stop calls. * * @param description Description of the following undoable operations. */ public void startUndo(String description) { if (storedDescription != null) { logUndoError("startUndo called while storedDescription=" + storedDescription + " description=" + description); } log.info("Starting time-limited undoable operation '" + description + "' for " + this); String store = nextDescription; addUndoPosition(description); storedDescription = store; } /** * End the previous time-limited undoable operation. This must be called after * {@link #startUndo(String)} has been called before any other undo operations are * performed. */ public void stopUndo() { log.info("Ending time-limited undoable operation for " + this + " nextDescription=" + nextDescription + " storedDescription=" + storedDescription); String stored = storedDescription; storedDescription = null; addUndoPosition(stored); } /** * Clear the undo history. */ public void clearUndo() { log.info("Clearing undo history of " + this); undoHistory.clear(); undoDescription.clear(); undoHistory.add(rocket.copyWithOriginalID()); undoDescription.add(null); undoPosition = 0; fireUndoRedoChangeEvent(); } @Override public void componentChanged(ComponentChangeEvent e) { if (!e.isUndoChange()) { if (undoPosition < undoHistory.size() - 1) { log.info("Rocket changed while in undo history, removing redo information for " + this + " undoPosition=" + undoPosition + " undoHistory.size=" + undoHistory.size() + " isClean=" + isCleanState()); } // Remove any redo information if available while (undoPosition < undoHistory.size() - 1) { undoHistory.removeLast(); undoDescription.removeLast(); } // Set the latest description undoDescription.set(undoPosition, nextDescription); } fireUndoRedoChangeEvent(); } /** * Return whether undo action is available. * @return <code>true</code> if undo can be performed */ public boolean isUndoAvailable() { if (undoPosition > 0) return true; return !isCleanState(); } /** * Return the description of what action would be undone if undo is called. * @return the description what would be undone, or <code>null</code> if description unavailable. */ public String getUndoDescription() { if (!isUndoAvailable()) return null; if (isCleanState()) { return undoDescription.get(undoPosition - 1); } else { return undoDescription.get(undoPosition); } } /** * Return whether redo action is available. * @return <code>true</code> if redo can be performed */ public boolean isRedoAvailable() { return undoPosition < undoHistory.size() - 1; } /** * Return the description of what action would be redone if redo is called. * @return the description of what would be redone, or <code>null</code> if description unavailable. */ public String getRedoDescription() { if (!isRedoAvailable()) return null; return undoDescription.get(undoPosition); } /** * Perform undo operation on the rocket. */ public void undo() { log.info("Performing undo for " + this + " undoPosition=" + undoPosition + " undoHistory.size=" + undoHistory.size() + " isClean=" + isCleanState()); if (!isUndoAvailable()) { logUndoError("Undo not available"); fireUndoRedoChangeEvent(); return; } if (storedDescription != null) { logUndoError("undo() called with storedDescription=" + storedDescription); } // Update history position if (isCleanState()) { // We are in a clean state, simply move backwards in history undoPosition--; } else { if (undoPosition != undoHistory.size() - 1) { logUndoError("undo position inconsistency"); } // Modifications have been made, save the state and restore previous state undoHistory.add(rocket.copyWithOriginalID()); undoDescription.add(null); } rocket.checkComponentStructure(); undoHistory.get(undoPosition).checkComponentStructure(); undoHistory.get(undoPosition).copyWithOriginalID().checkComponentStructure(); rocket.loadFrom(undoHistory.get(undoPosition).copyWithOriginalID()); rocket.checkComponentStructure(); } /** * Perform redo operation on the rocket. */ public void redo() { log.info("Performing redo for " + this + " undoPosition=" + undoPosition + " undoHistory.size=" + undoHistory.size() + " isClean=" + isCleanState()); if (!isRedoAvailable()) { logUndoError("Redo not available"); fireUndoRedoChangeEvent(); return; } if (storedDescription != null) { logUndoError("redo() called with storedDescription=" + storedDescription); } undoPosition++; rocket.loadFrom(undoHistory.get(undoPosition).copyWithOriginalID()); } private boolean isCleanState() { return rocket.getModID() == undoHistory.get(undoPosition).getModID(); } /** * Log a non-fatal undo/redo error or inconsistency. Reports it to the user the first * time it occurs, but not on subsequent times. Logs automatically the undo system state. */ private void logUndoError(String error) { log.error(error + ": this=" + this + " undoPosition=" + undoPosition + " undoHistory.size=" + undoHistory.size() + " isClean=" + isCleanState() + " nextDescription=" + nextDescription + " storedDescription=" + storedDescription, new Throwable()); if (!undoErrorReported) { undoErrorReported = true; Application.getExceptionHandler().handleErrorCondition("Undo/Redo error: " + error); } } /** * Return a copy of this document. The rocket is copied with original ID's, the default * motor configuration ID is maintained and the simulations are copied to the new rocket. * No undo/redo information or file storage information is maintained. * * This function is used from the Optimization routine to store alternatives of the same rocket. * For now we can assume that the copy returned does not have any of the attachment factories in place. * * @return a copy of this document. */ public OpenRocketDocument copy() { Rocket rocketCopy = rocket.copyWithOriginalID(); OpenRocketDocument documentCopy = OpenRocketDocumentFactory.createDocumentFromRocket(rocketCopy); documentCopy.getDefaultConfiguration().setFlightConfigurationID(configuration.getFlightConfigurationID()); for (Simulation s : simulations) { documentCopy.addSimulation(s.duplicateSimulation(rocketCopy)); } return documentCopy; } /////// Listeners public void addUndoRedoListener(UndoRedoListener listener) { undoRedoListeners.add(listener); } public void removeUndoRedoListener(UndoRedoListener listener) { undoRedoListeners.remove(listener); } private void fireUndoRedoChangeEvent() { UndoRedoListener[] array = undoRedoListeners.toArray(new UndoRedoListener[0]); for (UndoRedoListener l : array) { l.setAllValues(); } } public void addDocumentChangeListener(DocumentChangeListener listener) { listeners.add(listener); } public void removeDocumentChangeListener(DocumentChangeListener listener) { listeners.remove(listener); } protected void fireDocumentChangeEvent(DocumentChangeEvent event) { DocumentChangeListener[] array = listeners.toArray(new DocumentChangeListener[0]); for (DocumentChangeListener l : array) { l.documentChanged(event); } } }