mirror of
				https://github.com/SoftFever/OrcaSlicer.git
				synced 2025-10-30 20:21:12 -06:00 
			
		
		
		
	WIP: Undo / Redo memory limiting by releasing the least recently
used snapshots. Memory limit set to 10% of physical system memory.
This commit is contained in:
		
							parent
							
								
									4865240a9c
								
							
						
					
					
						commit
						3a74e7ab69
					
				
					 12 changed files with 411 additions and 82 deletions
				
			
		|  | @ -534,7 +534,7 @@ void GUI_App::persist_window_geometry(wxTopLevelWindow *window, bool default_max | |||
|     }); | ||||
| } | ||||
| 
 | ||||
| void GUI_App::load_project(wxWindow *parent, wxString& input_file) | ||||
| void GUI_App::load_project(wxWindow *parent, wxString& input_file) const | ||||
| { | ||||
|     input_file.Clear(); | ||||
|     wxFileDialog dialog(parent ? parent : GetTopWindow(), | ||||
|  | @ -546,7 +546,7 @@ void GUI_App::load_project(wxWindow *parent, wxString& input_file) | |||
|         input_file = dialog.GetPath(); | ||||
| } | ||||
| 
 | ||||
| void GUI_App::import_model(wxWindow *parent, wxArrayString& input_files) | ||||
| void GUI_App::import_model(wxWindow *parent, wxArrayString& input_files) const | ||||
| { | ||||
|     input_files.Clear(); | ||||
|     wxFileDialog dialog(parent ? parent : GetTopWindow(), | ||||
|  |  | |||
|  | @ -121,8 +121,8 @@ public: | |||
|     void            recreate_GUI(); | ||||
|     void            system_info(); | ||||
|     void            keyboard_shortcuts(); | ||||
|     void            load_project(wxWindow *parent, wxString& input_file); | ||||
|     void            import_model(wxWindow *parent, wxArrayString& input_files); | ||||
|     void            load_project(wxWindow *parent, wxString& input_file) const; | ||||
|     void            import_model(wxWindow *parent, wxArrayString& input_files) const; | ||||
|     static bool     catch_error(std::function<void()> cb, const std::string& err); | ||||
| 
 | ||||
|     void            persist_window_geometry(wxTopLevelWindow *window, bool default_maximized = false); | ||||
|  |  | |||
|  | @ -71,9 +71,6 @@ static void take_snapshot(const wxString& snapshot_name) | |||
|     wxGetApp().plater()->take_snapshot(snapshot_name); | ||||
| } | ||||
| 
 | ||||
| static void suppress_snapshots(){ wxGetApp().plater()->suppress_snapshots(); } | ||||
| static void allow_snapshots()   { wxGetApp().plater()->allow_snapshots(); } | ||||
| 
 | ||||
| ObjectList::ObjectList(wxWindow* parent) : | ||||
|     wxDataViewCtrl(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxDV_MULTIPLE), | ||||
|     m_parent(parent) | ||||
|  | @ -2406,8 +2403,7 @@ void ObjectList::remove() | |||
| 
 | ||||
|     wxDataViewItem  parent = wxDataViewItem(0); | ||||
| 
 | ||||
|     take_snapshot(_(L("Delete Selected"))); | ||||
|     suppress_snapshots(); | ||||
|     Plater::TakeSnapshot snapshot(wxGetApp().plater(), _(L("Delete Selected"))); | ||||
| 
 | ||||
|     for (auto& item : sels) | ||||
|     { | ||||
|  | @ -2429,8 +2425,6 @@ void ObjectList::remove() | |||
| 
 | ||||
|     if (parent) | ||||
|         select_item(parent); | ||||
| 
 | ||||
|     allow_snapshots(); | ||||
| } | ||||
| 
 | ||||
| void ObjectList::del_layer_range(const t_layer_height_range& range) | ||||
|  | @ -2505,8 +2499,7 @@ void ObjectList::add_layer_range_after_current(const t_layer_height_range& curre | |||
|              | ||||
|             t_layer_height_range new_range = { midl_layer, next_range.second }; | ||||
| 
 | ||||
|             take_snapshot(_(L("Add New Layers Range"))); | ||||
|             suppress_snapshots(); | ||||
|             Plater::TakeSnapshot snapshot(wxGetApp().plater(), _(L("Add New Layers Range"))); | ||||
| 
 | ||||
|             // create new 2 layers instead of deleted one
 | ||||
| 
 | ||||
|  | @ -2521,7 +2514,6 @@ void ObjectList::add_layer_range_after_current(const t_layer_height_range& curre | |||
|             new_range = { current_range.second, midl_layer }; | ||||
|             ranges[new_range] = get_default_layer_config(obj_idx); | ||||
|             add_layer_item(new_range, layers_item, layer_idx); | ||||
|             allow_snapshots(); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|  | @ -3531,7 +3523,7 @@ void ObjectList::update_after_undo_redo() | |||
|     m_prevent_list_events = true; | ||||
|     m_prevent_canvas_selection_update = true; | ||||
| 
 | ||||
|     suppress_snapshots(); | ||||
|     Plater::SuppressSnapshots suppress(wxGetApp().plater()); | ||||
| 
 | ||||
|     // Unselect all objects before deleting them, so that no change of selection is emitted during deletion.
 | ||||
|     this->UnselectAll(); | ||||
|  | @ -3543,8 +3535,6 @@ void ObjectList::update_after_undo_redo() | |||
|         ++obj_idx; | ||||
|     } | ||||
| 
 | ||||
|     allow_snapshots(); | ||||
| 
 | ||||
| #ifndef __WXOSX__  | ||||
|     selection_changed(); | ||||
| #endif /* __WXOSX__ */ | ||||
|  |  | |||
|  | @ -1255,13 +1255,9 @@ const std::regex PlaterDropTarget::pattern_drop(".*[.](stl|obj|amf|3mf|prusa)", | |||
| 
 | ||||
| bool PlaterDropTarget::OnDropFiles(wxCoord x, wxCoord y, const wxArrayString &filenames) | ||||
| { | ||||
| 	plater->take_snapshot(_(L("Load Files"))); | ||||
| 
 | ||||
|     std::vector<fs::path> paths; | ||||
| 
 | ||||
|     for (const auto &filename : filenames) { | ||||
|         fs::path path(into_path(filename)); | ||||
| 
 | ||||
|         if (std::regex_match(path.string(), pattern_drop)) { | ||||
|             paths.push_back(std::move(path)); | ||||
|         } else { | ||||
|  | @ -1269,6 +1265,23 @@ bool PlaterDropTarget::OnDropFiles(wxCoord x, wxCoord y, const wxArrayString &fi | |||
|         } | ||||
|     } | ||||
| 
 | ||||
| 	wxString snapshot_label; | ||||
| 	assert(! paths.empty()); | ||||
| 	if (paths.size() == 1) { | ||||
| 		snapshot_label = _(L("Load File")); | ||||
| 		snapshot_label += ": "; | ||||
| 		snapshot_label += wxString::FromUTF8(paths.front().filename().string().c_str()); | ||||
| 	} else { | ||||
| 		snapshot_label = _(L("Load Files")); | ||||
| 		snapshot_label += ": "; | ||||
| 		snapshot_label += wxString::FromUTF8(paths.front().filename().string().c_str()); | ||||
| 		for (size_t i = 1; i < paths.size(); ++ i) { | ||||
| 			snapshot_label += ", "; | ||||
| 			snapshot_label += wxString::FromUTF8(paths[i].filename().string().c_str()); | ||||
| 		} | ||||
| 	} | ||||
| 	Plater::TakeSnapshot snapshot(plater, snapshot_label); | ||||
| 
 | ||||
|     // FIXME: when drag and drop is done on a .3mf or a .amf file we should clear the plater for consistence with the open project command
 | ||||
|     // (the following call to plater->load_files() will load the config data, if present)
 | ||||
| 
 | ||||
|  | @ -1595,7 +1608,7 @@ struct Plater::priv | |||
|     priv(Plater *q, MainFrame *main_frame); | ||||
|     ~priv(); | ||||
| 
 | ||||
|     void update(bool force_full_scene_refresh = false); | ||||
|     void update(bool force_full_scene_refresh = false, bool force_background_processing_update = false); | ||||
|     void select_view(const std::string& direction); | ||||
|     void select_view_3D(const std::string& name); | ||||
|     void select_next_view_3D(); | ||||
|  | @ -1634,6 +1647,8 @@ struct Plater::priv | |||
|             return; | ||||
|         assert(this->m_prevent_snapshots >= 0); | ||||
| 	    this->undo_redo_stack.take_snapshot(snapshot_name, model, view3D->get_canvas3d()->get_selection()); | ||||
| 	    this->undo_redo_stack.release_least_recently_used(); | ||||
|     	BOOST_LOG_TRIVIAL(info) << "Undo / Redo snapshot taken: " << snapshot_name << ", Undo / Redo stack memory: " << Slic3r::format_memsize_MB(this->undo_redo_stack.memsize()) << log_memory_info(); | ||||
| 	} | ||||
| 	void take_snapshot(const wxString& snapshot_name) { this->take_snapshot(std::string(snapshot_name.ToUTF8().data())); } | ||||
|     int  get_active_snapshot_index(); | ||||
|  | @ -1734,7 +1749,7 @@ private: | |||
| 
 | ||||
|     void update_fff_scene(); | ||||
|     void update_sla_scene(); | ||||
|     void update_after_undo_redo(); | ||||
|     void update_after_undo_redo(bool temp_snapshot_was_taken = false); | ||||
| 
 | ||||
|     // path to project file stored with no extension
 | ||||
|     wxString m_project_filename; | ||||
|  | @ -1888,7 +1903,7 @@ Plater::priv::~priv() | |||
|         delete config; | ||||
| } | ||||
| 
 | ||||
| void Plater::priv::update(bool force_full_scene_refresh) | ||||
| void Plater::priv::update(bool force_full_scene_refresh, bool force_background_processing_update) | ||||
| { | ||||
|     // the following line, when enabled, causes flickering on NVIDIA graphics cards
 | ||||
| //    wxWindowUpdateLocker freeze_guard(q);
 | ||||
|  | @ -1901,7 +1916,7 @@ void Plater::priv::update(bool force_full_scene_refresh) | |||
|     } | ||||
| 
 | ||||
|     unsigned int update_status = 0; | ||||
|     if (this->printer_technology == ptSLA) | ||||
|     if (this->printer_technology == ptSLA || force_background_processing_update) | ||||
|         // Update the SLAPrint from the current Model, so that the reload_scene()
 | ||||
|         // pulls the correct data.
 | ||||
|         update_status = this->update_background_process(false); | ||||
|  | @ -2459,7 +2474,10 @@ void Plater::priv::remove(size_t obj_idx) | |||
| 
 | ||||
| void Plater::priv::delete_object_from_model(size_t obj_idx) | ||||
| { | ||||
|     this->take_snapshot(_(L("Delete Object"))); | ||||
| 	wxString snapshot_label = _(L("Delete Object")); | ||||
| 	if (! model.objects[obj_idx]->name.empty()) | ||||
| 		snapshot_label += ": " + wxString::FromUTF8(model.objects[obj_idx]->name.c_str()); | ||||
| 	Plater::TakeSnapshot snapshot(q, snapshot_label); | ||||
|     model.delete_object(obj_idx); | ||||
|     update(); | ||||
|     object_list_changed(); | ||||
|  | @ -2467,7 +2485,7 @@ void Plater::priv::delete_object_from_model(size_t obj_idx) | |||
| 
 | ||||
| void Plater::priv::reset() | ||||
| { | ||||
| 	this->take_snapshot(_(L("Reset Project"))); | ||||
| 	Plater::TakeSnapshot snapshot(q, _(L("Reset Project"))); | ||||
| 
 | ||||
|     set_project_filename(wxEmptyString); | ||||
| 
 | ||||
|  | @ -2663,7 +2681,7 @@ void Plater::priv::split_object() | |||
|         Slic3r::GUI::warning_catcher(q, _(L("The selected object couldn't be split because it contains only one part."))); | ||||
|     else | ||||
|     { | ||||
|         this->take_snapshot(_(L("Split to Objects"))); | ||||
| 		Plater::TakeSnapshot snapshot(q, _(L("Split to Objects"))); | ||||
| 
 | ||||
|         unsigned int counter = 1; | ||||
|         for (ModelObject* m : new_objects) | ||||
|  | @ -2903,7 +2921,7 @@ void Plater::priv::update_sla_scene() | |||
| 
 | ||||
| void Plater::priv::reload_from_disk() | ||||
| { | ||||
|     this->take_snapshot(_(L("Reload from Disk"))); | ||||
| 	Plater::TakeSnapshot snapshot(q, _(L("Reload from Disk"))); | ||||
| 
 | ||||
|     const auto &selection = get_selection(); | ||||
|     const auto obj_orig_idx = selection.get_object_idx(); | ||||
|  | @ -2939,7 +2957,7 @@ void Plater::priv::fix_through_netfabb(const int obj_idx, const int vol_idx/* = | |||
|     if (obj_idx < 0) | ||||
|         return; | ||||
| 
 | ||||
|     this->take_snapshot(_(L("Fix Throught NetFabb"))); | ||||
| 	Plater::TakeSnapshot snapshot(q, _(L("Fix Throught NetFabb"))); | ||||
| 
 | ||||
|     fix_model_by_win10_sdk_gui(*model.objects[obj_idx], vol_idx); | ||||
|     this->update(); | ||||
|  | @ -3603,7 +3621,7 @@ void Plater::priv::show_action_buttons(const bool is_ready_to_slice) const | |||
| 
 | ||||
| int Plater::priv::get_active_snapshot_index() | ||||
| { | ||||
|     const size_t& active_snapshot_time = this->undo_redo_stack.active_snapshot_time(); | ||||
|     const size_t active_snapshot_time = this->undo_redo_stack.active_snapshot_time(); | ||||
|     const std::vector<UndoRedo::Snapshot>& ss_stack = this->undo_redo_stack.snapshots(); | ||||
|     const auto it = std::lower_bound(ss_stack.begin(), ss_stack.end(), UndoRedo::Snapshot(active_snapshot_time)); | ||||
|     return it - ss_stack.begin(); | ||||
|  | @ -3611,8 +3629,9 @@ int Plater::priv::get_active_snapshot_index() | |||
| 
 | ||||
| void Plater::priv::undo() | ||||
| { | ||||
| 	bool temp_snapshot_was_taken = this->undo_redo_stack.temp_snapshot_active(); | ||||
| 	if (this->undo_redo_stack.undo(model, this->view3D->get_canvas3d()->get_selection())) | ||||
| 		this->update_after_undo_redo(); | ||||
| 		this->update_after_undo_redo(temp_snapshot_was_taken); | ||||
| } | ||||
| 
 | ||||
| void Plater::priv::redo() | ||||
|  | @ -3623,8 +3642,9 @@ void Plater::priv::redo() | |||
| 
 | ||||
| void Plater::priv::undo_to(size_t time_to_load) | ||||
| { | ||||
| 	bool temp_snapshot_was_taken = this->undo_redo_stack.temp_snapshot_active(); | ||||
| 	if (this->undo_redo_stack.undo(model, this->view3D->get_canvas3d()->get_selection(), time_to_load)) | ||||
| 		this->update_after_undo_redo(); | ||||
| 		this->update_after_undo_redo(temp_snapshot_was_taken); | ||||
| } | ||||
| 
 | ||||
| void Plater::priv::redo_to(size_t time_to_load) | ||||
|  | @ -3633,10 +3653,16 @@ void Plater::priv::redo_to(size_t time_to_load) | |||
| 		this->update_after_undo_redo(); | ||||
| } | ||||
| 
 | ||||
| void Plater::priv::update_after_undo_redo() | ||||
| void Plater::priv::update_after_undo_redo(bool /* temp_snapshot_was_taken */) | ||||
| { | ||||
| 	this->view3D->get_canvas3d()->get_selection().clear(); | ||||
| 	this->update(false); // update volumes from the deserializd model
 | ||||
| 	// Update volumes from the deserializd model, always stop / update the background processing (for both the SLA and FFF technologies).
 | ||||
| 	this->update(false, true); | ||||
| 	// Release old snapshots if the memory allocated is excessive. This may remove the top most snapshot if jumping to the very first snapshot.
 | ||||
| 	//if (temp_snapshot_was_taken)
 | ||||
| 	// Release the old snapshots always, as it may have happened, that some of the triangle meshes got deserialized from the snapshot, while some
 | ||||
| 	// triangle meshes may have gotten released from the scene or the background processing, therefore now being calculated into the Undo / Redo stack size.
 | ||||
| 		this->undo_redo_stack.release_least_recently_used(); | ||||
| 	//YS_FIXME update obj_list from the deserialized model (maybe store ObjectIDs into the tree?) (no selections at this point of time)
 | ||||
|     this->view3D->get_canvas3d()->get_selection().set_deserialized(GUI::Selection::EMode(this->undo_redo_stack.selection_deserialized().mode), this->undo_redo_stack.selection_deserialized().volumes_and_instances); | ||||
| 
 | ||||
|  | @ -3644,6 +3670,8 @@ void Plater::priv::update_after_undo_redo() | |||
| 
 | ||||
| 	//FIXME what about the state of the manipulators?
 | ||||
| 	//FIXME what about the focus? Cursor in the side panel?
 | ||||
| 
 | ||||
|     BOOST_LOG_TRIVIAL(info) << "Undo / Redo snapshot reloaded. Undo / Redo stack memory: " << Slic3r::format_memsize_MB(this->undo_redo_stack.memsize()) << log_memory_info(); | ||||
| } | ||||
| 
 | ||||
| void Sidebar::set_btn_label(const ActionButtonType btn_type, const wxString& label) const | ||||
|  | @ -3683,10 +3711,12 @@ void Plater::new_project() | |||
| 
 | ||||
| void Plater::load_project() | ||||
| { | ||||
| 	this->take_snapshot(_(L("Load Project"))); | ||||
| 
 | ||||
|     // Ask user for a project file name.
 | ||||
|     wxString input_file; | ||||
|     wxGetApp().load_project(this, input_file); | ||||
|     // Take the Undo / Redo snapshot.
 | ||||
| 	Plater::TakeSnapshot snapshot(this, _(L("Load Project")) + ": " + wxString::FromUTF8(into_path(input_file).stem().string().c_str())); | ||||
|     // And finally load the new project.
 | ||||
|     load_project(input_file); | ||||
| } | ||||
| 
 | ||||
|  | @ -3710,13 +3740,28 @@ void Plater::add_model() | |||
|     if (input_files.empty()) | ||||
|         return; | ||||
| 
 | ||||
|     this->take_snapshot(_(L("Add object(s)"))); | ||||
|     std::vector<fs::path> paths; | ||||
|     for (const auto &file : input_files) | ||||
|         paths.push_back(into_path(file)); | ||||
| 
 | ||||
|     std::vector<fs::path> input_paths; | ||||
|     for (const auto &file : input_files) { | ||||
|         input_paths.push_back(into_path(file)); | ||||
|     } | ||||
|     load_files(input_paths, true, false); | ||||
| 	wxString snapshot_label; | ||||
| 	assert(! paths.empty()); | ||||
| 	if (paths.size() == 1) { | ||||
| 		snapshot_label = _(L("Import Object")); | ||||
| 		snapshot_label += ": "; | ||||
| 		snapshot_label += wxString::FromUTF8(paths.front().filename().string().c_str()); | ||||
| 	} else { | ||||
| 		snapshot_label = _(L("Import Objects")); | ||||
| 		snapshot_label += ": "; | ||||
| 		snapshot_label += wxString::FromUTF8(paths.front().filename().string().c_str()); | ||||
| 		for (size_t i = 1; i < paths.size(); ++ i) { | ||||
| 			snapshot_label += ", "; | ||||
| 			snapshot_label += wxString::FromUTF8(paths[i].filename().string().c_str()); | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	Plater::TakeSnapshot snapshot(this, snapshot_label); | ||||
|     load_files(paths, true, false); | ||||
| } | ||||
| 
 | ||||
| void Plater::extract_config_from_project() | ||||
|  | @ -3769,17 +3814,15 @@ void Plater::delete_object_from_model(size_t obj_idx) { p->delete_object_from_mo | |||
| 
 | ||||
| void Plater::remove_selected() | ||||
| { | ||||
| 	this->take_snapshot(_(L("Delete Selected Objects"))); | ||||
|     this->suppress_snapshots(); | ||||
| 	Plater::TakeSnapshot snapshot(this, _(L("Delete Selected Objects"))); | ||||
|     this->p->view3D->delete_selected(); | ||||
|     this->allow_snapshots(); | ||||
| } | ||||
| 
 | ||||
| void Plater::increase_instances(size_t num) | ||||
| { | ||||
|     if (! can_increase_instances()) { return; } | ||||
| 
 | ||||
| 	this->take_snapshot(_(L("Increase Instances"))); | ||||
| 	Plater::TakeSnapshot snapshot(this, _(L("Increase Instances"))); | ||||
| 
 | ||||
|     int obj_idx = p->get_selected_object_idx(); | ||||
| 
 | ||||
|  | @ -3815,7 +3858,7 @@ void Plater::decrease_instances(size_t num) | |||
| { | ||||
|     if (! can_decrease_instances()) { return; } | ||||
| 
 | ||||
| 	this->take_snapshot(_(L("Decrease Instances"))); | ||||
| 	Plater::TakeSnapshot snapshot(this, _(L("Decrease Instances"))); | ||||
| 
 | ||||
|     int obj_idx = p->get_selected_object_idx(); | ||||
| 
 | ||||
|  | @ -3851,16 +3894,13 @@ void Plater::set_number_of_copies(/*size_t num*/) | |||
|     if (num < 0) | ||||
|         return; | ||||
| 
 | ||||
|     this->take_snapshot(wxString::Format(_(L("Set numbers of copies to %d")), num)); | ||||
|     this->suppress_snapshots(); | ||||
| 	Plater::TakeSnapshot snapshot(this, wxString::Format(_(L("Set numbers of copies to %d")), num)); | ||||
| 
 | ||||
|     int diff = (int)num - (int)model_object->instances.size(); | ||||
|     if (diff > 0) | ||||
|         increase_instances(diff); | ||||
|     else if (diff < 0) | ||||
|         decrease_instances(-diff); | ||||
| 
 | ||||
|     this->allow_snapshots(); | ||||
| } | ||||
| 
 | ||||
| bool Plater::is_selection_empty() const | ||||
|  | @ -3884,7 +3924,7 @@ void Plater::cut(size_t obj_idx, size_t instance_idx, coordf_t z, bool keep_uppe | |||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     this->take_snapshot(_(L("Cut"))); | ||||
| 	Plater::TakeSnapshot snapshot(this, _(L("Cut"))); | ||||
| 
 | ||||
|     wxBusyCursor wait; | ||||
|     const auto new_objects = object->cut(instance_idx, z, keep_upper, keep_lower, rotate_lower); | ||||
|  |  | |||
|  | @ -186,8 +186,6 @@ public: | |||
| 
 | ||||
|     void take_snapshot(const std::string &snapshot_name); | ||||
|     void take_snapshot(const wxString &snapshot_name); | ||||
|     void suppress_snapshots(); | ||||
|     void allow_snapshots(); | ||||
|     void undo(); | ||||
|     void redo(); | ||||
|     void undo_to(int selection); | ||||
|  | @ -235,10 +233,46 @@ public: | |||
| 
 | ||||
|     const Camera& get_camera() const; | ||||
| 
 | ||||
| 	// ROII wrapper for suppressing the Undo / Redo snapshot to be taken.
 | ||||
| 	class SuppressSnapshots | ||||
| 	{ | ||||
| 	public: | ||||
| 		SuppressSnapshots(Plater *plater) : m_plater(plater) | ||||
| 		{ | ||||
| 			m_plater->suppress_snapshots(); | ||||
| 		} | ||||
| 		~SuppressSnapshots() | ||||
| 		{ | ||||
| 			m_plater->allow_snapshots(); | ||||
| 		} | ||||
| 	private: | ||||
| 		Plater *m_plater; | ||||
| 	}; | ||||
| 
 | ||||
| 	// ROII wrapper for taking an Undo / Redo snapshot while disabling the snapshot taking by the methods called from inside this snapshot.
 | ||||
| 	class TakeSnapshot | ||||
| 	{ | ||||
| 	public: | ||||
| 		TakeSnapshot(Plater *plater, const wxString &snapshot_name) : m_plater(plater) | ||||
| 		{ | ||||
| 			m_plater->take_snapshot(snapshot_name); | ||||
| 			m_plater->suppress_snapshots(); | ||||
| 		} | ||||
| 		~TakeSnapshot() | ||||
| 		{ | ||||
| 			m_plater->allow_snapshots(); | ||||
| 		} | ||||
| 	private: | ||||
| 		Plater *m_plater; | ||||
| 	}; | ||||
| 
 | ||||
| private: | ||||
|     struct priv; | ||||
|     std::unique_ptr<priv> p; | ||||
| 
 | ||||
|     void suppress_snapshots(); | ||||
|     void allow_snapshots(); | ||||
| 
 | ||||
|     friend class SuppressBackgroundProcessingUpdate; | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -18,6 +18,7 @@ | |||
| #include <cereal/archives/adapters.hpp> | ||||
| 
 | ||||
| #include <libslic3r/ObjectID.hpp> | ||||
| #include <libslic3r/Utils.hpp> | ||||
| 
 | ||||
| #include <boost/foreach.hpp> | ||||
| 
 | ||||
|  | @ -28,11 +29,11 @@ | |||
| namespace Slic3r { | ||||
| namespace UndoRedo { | ||||
| 
 | ||||
| static std::string topmost_snapsnot_name = "@@@ Topmost @@@"; | ||||
| static std::string topmost_snapshot_name = "@@@ Topmost @@@"; | ||||
| 
 | ||||
| bool Snapshot::is_topmost() const | ||||
| { | ||||
| 	return this->name == topmost_snapsnot_name; | ||||
| 	return this->name == topmost_snapshot_name; | ||||
| } | ||||
| 
 | ||||
| // Time interval, start is closed, end is open.
 | ||||
|  | @ -53,9 +54,12 @@ public: | |||
| 	bool    operator<(const Interval &rhs) const { return (m_begin < rhs.m_begin) || (m_begin == rhs.m_begin && m_end < rhs.m_end); } | ||||
| 	bool 	operator==(const Interval &rhs) const { return m_begin == rhs.m_begin && m_end == rhs.m_end; } | ||||
| 
 | ||||
| 	void 	trim_begin(size_t new_begin)  { m_begin = std::max(m_begin, new_begin); } | ||||
| 	void    trim_end(size_t new_end) { m_end = std::min(m_end, new_end); } | ||||
| 	void 	extend_end(size_t new_end) { assert(new_end >= m_end); m_end = new_end; } | ||||
| 
 | ||||
| 	size_t 	memsize() const { return sizeof(this); } | ||||
| 
 | ||||
| private: | ||||
| 	size_t 	m_begin; | ||||
| 	size_t 	m_end; | ||||
|  | @ -75,8 +79,15 @@ public: | |||
| 	// If the history is empty, the ObjectHistory object could be released.
 | ||||
| 	virtual bool empty() = 0; | ||||
| 
 | ||||
| 	// Release all data before the given timestamp. For the ImmutableObjectHistory, the shared pointer is NOT released.
 | ||||
| 	// Return the amount of memory released.
 | ||||
| 	virtual size_t release_before_timestamp(size_t timestamp) = 0; | ||||
| 	// Release all data after the given timestamp. For the ImmutableObjectHistory, the shared pointer is NOT released.
 | ||||
| 	virtual void release_after_timestamp(size_t timestamp) = 0; | ||||
| 	// Return the amount of memory released.
 | ||||
| 	virtual size_t release_after_timestamp(size_t timestamp) = 0; | ||||
| 
 | ||||
| 	// Estimated size in memory, to be used to drop least recently used snapshots.
 | ||||
| 	virtual size_t memsize() const = 0; | ||||
| 
 | ||||
| #ifdef SLIC3R_UNDOREDO_DEBUG | ||||
| 	// Human readable debug information.
 | ||||
|  | @ -96,21 +107,54 @@ public: | |||
| 	// If the history is empty, the ObjectHistory object could be released.
 | ||||
| 	bool empty() override { return m_history.empty(); } | ||||
| 
 | ||||
| 	// Release all data after the given timestamp. The shared pointer is NOT released.
 | ||||
| 	void release_after_timestamp(size_t timestamp) override { | ||||
| 		assert(! m_history.empty()); | ||||
| 		assert(this->valid()); | ||||
| 		// it points to an interval which either starts with timestamp, or follows the timestamp.
 | ||||
| 		auto it = std::lower_bound(m_history.begin(), m_history.end(), T(timestamp, timestamp)); | ||||
| 		if (it != m_history.begin()) { | ||||
| 			auto it_prev = it; | ||||
| 			-- it_prev; | ||||
| 			assert(it_prev->begin() < timestamp); | ||||
| 			// Trim the last interval with timestamp.
 | ||||
| 			it_prev->trim_end(timestamp); | ||||
| 	// Release all data before the given timestamp. For the ImmutableObjectHistory, the shared pointer is NOT released.
 | ||||
| 	size_t release_before_timestamp(size_t timestamp) override { | ||||
| 		size_t mem_released = 0; | ||||
| 		if (! m_history.empty()) { | ||||
| 			assert(this->valid()); | ||||
| 			// it points to an interval which either starts with timestamp, or follows the timestamp.
 | ||||
| 			auto it = std::lower_bound(m_history.begin(), m_history.end(), T(timestamp, timestamp)); | ||||
| 			// Find the first iterator with begin() < timestamp.
 | ||||
| 			if (it == m_history.end()) | ||||
| 				-- it; | ||||
| 			while (it != m_history.begin() && it->begin() >= timestamp) | ||||
| 				-- it; | ||||
| 			if (it->begin() < timestamp && it->end() > timestamp) { | ||||
| 				it->trim_begin(timestamp); | ||||
| 				if (it != m_history.begin()) | ||||
| 					-- it; | ||||
| 			} | ||||
| 			if (it->end() <= timestamp) { | ||||
| 				auto it_end = ++ it; | ||||
| 				for (it = m_history.begin(); it != it_end; ++ it) | ||||
| 					mem_released += it->memsize(); | ||||
| 				m_history.erase(m_history.begin(), it_end); | ||||
| 			} | ||||
| 			assert(this->valid()); | ||||
| 		} | ||||
| 		m_history.erase(it, m_history.end()); | ||||
| 		assert(this->valid()); | ||||
| 		return mem_released; | ||||
| 	} | ||||
| 
 | ||||
| 	// Release all data after the given timestamp. The shared pointer is NOT released.
 | ||||
| 	size_t release_after_timestamp(size_t timestamp) override { | ||||
| 		size_t mem_released = 0; | ||||
| 		if (! m_history.empty()) { | ||||
| 			assert(this->valid()); | ||||
| 			// it points to an interval which either starts with timestamp, or follows the timestamp.
 | ||||
| 			auto it = std::lower_bound(m_history.begin(), m_history.end(), T(timestamp, timestamp)); | ||||
| 			if (it != m_history.begin()) { | ||||
| 				auto it_prev = it; | ||||
| 				-- it_prev; | ||||
| 				assert(it_prev->begin() < timestamp); | ||||
| 				// Trim the last interval with timestamp.
 | ||||
| 				it_prev->trim_end(timestamp); | ||||
| 			} | ||||
| 			for (auto it2 = it; it2 != m_history.end(); ++ it2) | ||||
| 				mem_released += it2->memsize(); | ||||
| 			m_history.erase(it, m_history.end()); | ||||
| 			assert(this->valid()); | ||||
| 		} | ||||
| 		return mem_released; | ||||
| 	} | ||||
| 
 | ||||
| protected: | ||||
|  | @ -138,6 +182,18 @@ public: | |||
| 	bool is_immutable() const override { return true; } | ||||
| 	const void* immutable_object_ptr() const { return (const void*)m_shared_object.get(); } | ||||
| 
 | ||||
| 	// Estimated size in memory, to be used to drop least recently used snapshots.
 | ||||
| 	size_t memsize() const override { | ||||
| 		size_t memsize = sizeof(*this); | ||||
| 		if (this->is_serialized()) | ||||
| 			memsize += m_serialized.size(); | ||||
| 		else if (m_shared_object.use_count() == 1) | ||||
| 			// Only count the shared object's memsize into the total Undo / Redo stack memsize if it is referenced from the Undo / Redo stack only.
 | ||||
| 			memsize += m_shared_object->memsize(); | ||||
| 		memsize += m_history.size() * sizeof(Interval); | ||||
| 		return memsize; | ||||
| 	} | ||||
| 
 | ||||
| 	void save(size_t active_snapshot_time, size_t current_time) { | ||||
| 		assert(m_history.empty() || m_history.back().end() <= active_snapshot_time ||  | ||||
| 			// The snapshot of an immutable object may have already been taken from another mutable object.
 | ||||
|  | @ -230,7 +286,8 @@ public: | |||
| 	const Interval& interval() const { return m_interval; } | ||||
| 	size_t		begin() const { return m_interval.begin(); } | ||||
| 	size_t		end()   const { return m_interval.end(); } | ||||
| 	void 		trim_end(size_t timestamp) { m_interval.trim_end(timestamp); } | ||||
| 	void 		trim_begin(size_t timestamp) { m_interval.trim_begin(timestamp); } | ||||
| 	void 		trim_end  (size_t timestamp) { m_interval.trim_end(timestamp); } | ||||
| 	void 		extend_end(size_t timestamp) { m_interval.extend_end(timestamp); } | ||||
| 
 | ||||
| 	bool		operator<(const MutableHistoryInterval& rhs) const { return m_interval < rhs.m_interval; } | ||||
|  | @ -240,6 +297,13 @@ public: | |||
| 	size_t  	size() const { return m_data->size; } | ||||
| 	size_t		refcnt() const { return m_data->refcnt; } | ||||
| 	bool		matches(const std::string& data) { return m_data->matches(data); } | ||||
| 	size_t 		memsize() const {  | ||||
| 		return m_data->refcnt == 1 ? | ||||
| 			// Count just the size of the snapshot data.
 | ||||
| 			m_data->size : | ||||
| 			// Count the size of the snapshot data divided by the number of references, rounded up.
 | ||||
| 			(m_data->size + m_data->refcnt - 1) / m_data->refcnt; | ||||
| 	} | ||||
| 
 | ||||
| private: | ||||
| 	MutableHistoryInterval(const MutableHistoryInterval &rhs); | ||||
|  | @ -270,6 +334,15 @@ public: | |||
| 	bool is_mutable() const override { return true; } | ||||
| 	bool is_immutable() const override { return false; } | ||||
| 
 | ||||
| 	// Estimated size in memory, to be used to drop least recently used snapshots.
 | ||||
| 	size_t memsize() const override { | ||||
| 		size_t memsize = sizeof(*this); | ||||
| 		memsize += m_history.size() * sizeof(MutableHistoryInterval); | ||||
| 		for (const MutableHistoryInterval &interval : m_history) | ||||
| 			memsize += interval.memsize(); | ||||
| 		return memsize; | ||||
| 	} | ||||
| 
 | ||||
| 	void save(size_t active_snapshot_time, size_t current_time, const std::string &data) { | ||||
| 		assert(m_history.empty() || m_history.back().end() <= active_snapshot_time); | ||||
| 		if (m_history.empty() || m_history.back().end() < active_snapshot_time) { | ||||
|  | @ -356,7 +429,17 @@ class StackImpl | |||
| { | ||||
| public: | ||||
| 	// Stack needs to be initialized. An empty stack is not valid, there must be a "New Project" status stored at the beginning.
 | ||||
| 	StackImpl() : m_active_snapshot_time(0), m_current_time(0) {} | ||||
| 	// Initially enable Undo / Redo stack to occupy maximum 10% of the total system physical memory.
 | ||||
| 	StackImpl() : m_memory_limit(std::min(Slic3r::total_physical_memory() / 10, size_t(1 * 16384 * 65536))), m_active_snapshot_time(0), m_current_time(0) {} | ||||
| 
 | ||||
| 	void set_memory_limit(size_t memsize) { m_memory_limit = memsize; } | ||||
| 
 | ||||
| 	size_t memsize() const { | ||||
| 		size_t memsize = 0; | ||||
| 		for (const auto &object : m_objects) | ||||
| 			memsize += object.second->memsize(); | ||||
| 		return memsize; | ||||
| 	} | ||||
| 
 | ||||
| 	// The Undo / Redo stack is being initialized with an empty model and an empty selection.
 | ||||
| 	// The first snapshot cannot be removed.
 | ||||
|  | @ -370,13 +453,15 @@ public: | |||
| 	bool has_redo_snapshot() const; | ||||
| 	bool undo(Slic3r::Model &model, const Slic3r::GUI::Selection &selection, size_t jump_to_time); | ||||
| 	bool redo(Slic3r::Model &model, size_t jump_to_time); | ||||
| 	void release_least_recently_used(); | ||||
| 
 | ||||
| 	// Snapshot history (names with timestamps).
 | ||||
| 	const std::vector<Snapshot>& 	snapshots() const { return m_snapshots; } | ||||
| 	// Timestamp of the active snapshot.
 | ||||
| 	size_t 							active_snapshot_time() const { return m_active_snapshot_time; } | ||||
| 	bool 							temp_snapshot_active() const { return m_snapshots.back().timestamp == m_active_snapshot_time && ! m_snapshots.back().is_topmost_captured(); } | ||||
| 
 | ||||
| 	const Selection& selection_deserialized() const { return m_selection; } | ||||
| 	const Selection& 				selection_deserialized() const { return m_selection; } | ||||
| 
 | ||||
| //protected:
 | ||||
| 	template<typename T, typename T_AS> ObjectID save_mutable_object(const T &object); | ||||
|  | @ -400,6 +485,7 @@ public: | |||
| 		if (m_active_snapshot_time > m_snapshots.back().timestamp) | ||||
| 			out += ">>>\n"; | ||||
| 		out += "Current time: " + std::to_string(m_current_time) + "\n"; | ||||
| 		out += "Total memory occupied: " + std::to_string(this->memsize()) + "\n"; | ||||
| 		return out; | ||||
| 	} | ||||
| 	void print() const { | ||||
|  | @ -412,9 +498,10 @@ public: | |||
| #ifndef NDEBUG | ||||
| 	bool valid() const { | ||||
| 		assert(! m_snapshots.empty()); | ||||
| 		assert(m_snapshots.back().is_topmost()); | ||||
| 		auto it = std::lower_bound(m_snapshots.begin(), m_snapshots.end(), Snapshot(m_active_snapshot_time)); | ||||
| 		assert(it != m_snapshots.begin() && it != m_snapshots.end() && it->timestamp == m_active_snapshot_time); | ||||
| 		assert(m_active_snapshot_time < m_snapshots.back().timestamp || m_snapshots.back().is_topmost()); | ||||
| 		assert(m_active_snapshot_time <= m_snapshots.back().timestamp); | ||||
| 		for (auto it = m_objects.begin(); it != m_objects.end(); ++ it) | ||||
| 			assert(it->second->valid()); | ||||
| 		return true; | ||||
|  | @ -436,6 +523,9 @@ private: | |||
| 	} | ||||
| 	void 							collect_garbage(); | ||||
| 
 | ||||
| 	// Maximum memory allowed to be occupied by the Undo / Redo stack. If the limit is exceeded,
 | ||||
| 	// least recently used snapshots will be released.
 | ||||
| 	size_t 													m_memory_limit; | ||||
| 	// Each individual object (Model, ModelObject, ModelInstance, ModelVolume, Selection, TriangleMesh)
 | ||||
| 	// is stored with its own history, referenced by the ObjectID. Immutable objects do not provide
 | ||||
| 	// their own IDs, therefore there are temporary IDs generated for them and stored to m_shared_ptr_to_object_id.
 | ||||
|  | @ -655,7 +745,7 @@ void StackImpl::take_snapshot(const std::string &snapshot_name, const Slic3r::Mo | |||
| 	m_active_snapshot_time = m_current_time; | ||||
| 	// Save snapshot info of the last "current" aka "top most" state, that is only being serialized
 | ||||
| 	// if undoing an action. Such a snapshot has an invalid Model ID assigned if it was not taken yet.
 | ||||
| 	m_snapshots.emplace_back(topmost_snapsnot_name, m_active_snapshot_time, 0); | ||||
| 	m_snapshots.emplace_back(topmost_snapshot_name, m_active_snapshot_time, 0); | ||||
| 	// Release empty objects from the history.
 | ||||
| 	this->collect_garbage(); | ||||
| 	assert(this->valid()); | ||||
|  | @ -710,9 +800,10 @@ bool StackImpl::undo(Slic3r::Model &model, const Slic3r::GUI::Selection &selecti | |||
| 	} | ||||
| 	assert(time_to_load < m_active_snapshot_time); | ||||
| 	assert(std::binary_search(m_snapshots.begin(), m_snapshots.end(), Snapshot(time_to_load))); | ||||
| 	bool new_snapshot_taken = false; | ||||
| 	if (m_active_snapshot_time == m_snapshots.back().timestamp && ! m_snapshots.back().is_topmost_captured()) { | ||||
| 		// The current state is temporary. The current state needs to be captured to be redoable.
 | ||||
| 		this->take_snapshot(topmost_snapsnot_name, model, selection); | ||||
| 		this->take_snapshot(topmost_snapshot_name, model, selection); | ||||
| 		// The line above entered another topmost_snapshot_name.
 | ||||
| 		assert(m_snapshots.back().is_topmost()); | ||||
| 		assert(! m_snapshots.back().is_topmost_captured()); | ||||
|  | @ -722,8 +813,15 @@ bool StackImpl::undo(Slic3r::Model &model, const Slic3r::GUI::Selection &selecti | |||
| 		//-- m_current_time;
 | ||||
| 		assert(m_snapshots.back().is_topmost()); | ||||
| 		assert(m_snapshots.back().is_topmost_captured()); | ||||
| 		new_snapshot_taken = true; | ||||
| 	} | ||||
| 	this->load_snapshot(time_to_load, model); | ||||
| 	if (new_snapshot_taken) { | ||||
| 		// Release old snapshots if the memory allocated due to capturing the top most state is excessive.
 | ||||
| 		// Don't release the snapshots here, release them first after the scene and background processing gets updated, as this will release some references
 | ||||
| 		// to the shared TriangleMeshes.
 | ||||
| 		//this->release_least_recently_used();
 | ||||
| 	} | ||||
| #ifdef SLIC3R_UNDOREDO_DEBUG | ||||
| 	std::cout << "After undo" << std::endl; | ||||
|  	this->print(); | ||||
|  | @ -764,10 +862,70 @@ void StackImpl::collect_garbage() | |||
| 	} | ||||
| } | ||||
| 
 | ||||
| void StackImpl::release_least_recently_used() | ||||
| { | ||||
| 	assert(this->valid()); | ||||
| 	size_t current_memsize = this->memsize(); | ||||
| #ifdef SLIC3R_UNDOREDO_DEBUG | ||||
| 	bool released = false; | ||||
| #endif | ||||
| 	while (current_memsize > m_memory_limit && m_snapshots.size() >= 3) { | ||||
| 		// From which side to remove a snapshot?
 | ||||
| 		assert(m_snapshots.front().timestamp < m_active_snapshot_time); | ||||
| 		size_t mem_released = 0; | ||||
| 		if (m_snapshots[1].timestamp == m_active_snapshot_time) { | ||||
| 			// Remove the last snapshot.
 | ||||
| 			for (auto it = m_objects.begin(); it != m_objects.end();) { | ||||
| 				mem_released += it->second->release_after_timestamp(m_snapshots.back().timestamp); | ||||
| 				if (it->second->empty()) { | ||||
| 					if (it->second->immutable_object_ptr() != nullptr) | ||||
| 						// Release the immutable object from the ptr to ObjectID map.
 | ||||
| 						m_shared_ptr_to_object_id.erase(it->second->immutable_object_ptr()); | ||||
| 					mem_released += it->second->memsize(); | ||||
| 					it = m_objects.erase(it); | ||||
| 				} else | ||||
| 					++ it; | ||||
| 			} | ||||
| 			m_snapshots.pop_back(); | ||||
| 			m_snapshots.back().name = topmost_snapshot_name; | ||||
| 		} else { | ||||
| 			// Remove the first snapshot.
 | ||||
| 			for (auto it = m_objects.begin(); it != m_objects.end();) { | ||||
| 				mem_released += it->second->release_before_timestamp(m_snapshots[1].timestamp); | ||||
| 				if (it->second->empty()) { | ||||
| 					if (it->second->immutable_object_ptr() != nullptr) | ||||
| 						// Release the immutable object from the ptr to ObjectID map.
 | ||||
| 						m_shared_ptr_to_object_id.erase(it->second->immutable_object_ptr()); | ||||
| 					mem_released += it->second->memsize(); | ||||
| 					it = m_objects.erase(it); | ||||
| 				} else | ||||
| 					++ it; | ||||
| 			} | ||||
| 			m_snapshots.erase(m_snapshots.begin()); | ||||
| 		} | ||||
| 		assert(current_memsize >= mem_released); | ||||
| 		if (current_memsize >= mem_released) | ||||
| 			current_memsize -= mem_released; | ||||
| 		else | ||||
| 			current_memsize = 0; | ||||
| #ifdef SLIC3R_UNDOREDO_DEBUG | ||||
| 		released = true; | ||||
| #endif | ||||
| 	} | ||||
| 	assert(this->valid()); | ||||
| #ifdef SLIC3R_UNDOREDO_DEBUG | ||||
| 	std::cout << "After release_least_recently_used" << std::endl; | ||||
|  	this->print(); | ||||
| #endif /* SLIC3R_UNDOREDO_DEBUG */ | ||||
| } | ||||
| 
 | ||||
| // Wrappers of the private implementation.
 | ||||
| Stack::Stack() : pimpl(new StackImpl()) {} | ||||
| Stack::~Stack() {} | ||||
| void Stack::take_snapshot(const std::string &snapshot_name, const Slic3r::Model &model, const Slic3r::GUI::Selection &selection) { pimpl->take_snapshot(snapshot_name, model, selection); } | ||||
| void Stack::set_memory_limit(size_t memsize) { pimpl->set_memory_limit(memsize); } | ||||
| size_t Stack::memsize() const { return pimpl->memsize(); } | ||||
| void Stack::release_least_recently_used() { pimpl->release_least_recently_used(); } | ||||
| void Stack::take_snapshot(const std::string& snapshot_name, const Slic3r::Model& model, const Slic3r::GUI::Selection& selection) { pimpl->take_snapshot(snapshot_name, model, selection); } | ||||
| bool Stack::has_undo_snapshot() const { return pimpl->has_undo_snapshot(); } | ||||
| bool Stack::has_redo_snapshot() const { return pimpl->has_redo_snapshot(); } | ||||
| bool Stack::undo(Slic3r::Model &model, const Slic3r::GUI::Selection &selection, size_t time_to_load) { return pimpl->undo(model, selection, time_to_load); } | ||||
|  | @ -776,6 +934,7 @@ const Selection& Stack::selection_deserialized() const { return pimpl->selection | |||
| 
 | ||||
| const std::vector<Snapshot>& Stack::snapshots() const { return pimpl->snapshots(); } | ||||
| size_t Stack::active_snapshot_time() const { return pimpl->active_snapshot_time(); } | ||||
| bool Stack::temp_snapshot_active() const { return pimpl->temp_snapshot_active(); } | ||||
| 
 | ||||
| } // namespace UndoRedo
 | ||||
| } // namespace Slic3r
 | ||||
|  |  | |||
|  | @ -55,6 +55,15 @@ public: | |||
| 	Stack(); | ||||
| 	~Stack(); | ||||
| 
 | ||||
| 	// Set maximum memory threshold. If the threshold is exceeded, least recently used snapshots are released.
 | ||||
| 	void set_memory_limit(size_t memsize); | ||||
| 
 | ||||
| 	// Estimate size of the RAM consumed by the Undo / Redo stack.
 | ||||
| 	size_t memsize() const; | ||||
| 
 | ||||
| 	// Release least recently used snapshots up to the memory limit set above.
 | ||||
| 	void release_least_recently_used(); | ||||
| 
 | ||||
| 	// Store the current application state onto the Undo / Redo stack, remove all snapshots after m_active_snapshot_time.
 | ||||
| 	void take_snapshot(const std::string &snapshot_name, const Slic3r::Model &model, const Slic3r::GUI::Selection &selection); | ||||
| 
 | ||||
|  | @ -78,6 +87,9 @@ public: | |||
| 	// The snapshot time indicates start of an operation, which is finished at the time of the following snapshot, therefore
 | ||||
| 	// the active snapshot is the successive snapshot. The same logic applies to the time_to_load parameter of undo() and redo() operations.
 | ||||
| 	size_t active_snapshot_time() const; | ||||
| 	// Temporary snapshot is active if the topmost snapshot is active and it has not been captured yet.
 | ||||
| 	// In that case the Undo action will capture the last snapshot.
 | ||||
| 	bool   temp_snapshot_active() const; | ||||
| 
 | ||||
| 	// After load_snapshot() / undo() / redo() the selection is deserialized into a list of ObjectIDs, which needs to be converted
 | ||||
| 	// into the list of GLVolume pointers once the 3D scene is updated.
 | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 bubnikv
						bubnikv