diff --git a/amulet_map_editor/amulet_ui.py b/amulet_map_editor/amulet_ui.py index 24c0b1cf..f5bc9bc7 100644 --- a/amulet_map_editor/amulet_ui.py +++ b/amulet_map_editor/amulet_ui.py @@ -1,15 +1,21 @@ import wx +import wx.lib.inspection +from wx.lib.agw import flatnotebook import os -from typing import Dict +from typing import Dict, Union import webbrowser from amulet.api.errors import LoaderNoneMatched -from amulet_map_editor.amulet_wx.world_select import WorldSelectDialog +from amulet_map_editor.amulet_wx.ui.select_world import WorldSelectDialog from amulet_map_editor import lang, version, log, IMG_DIR from amulet_map_editor.programs import WorldManagerUI -from amulet_map_editor.amulet_wx import simple from amulet_map_editor.programs import BaseWorldUI +NOTEBOOK_MENU_STYLE = flatnotebook.FNB_NO_X_BUTTON | flatnotebook.FNB_HIDE_ON_SINGLE_TAB | flatnotebook.FNB_NAV_BUTTONS_WHEN_NEEDED +NOTEBOOK_STYLE = NOTEBOOK_MENU_STYLE | flatnotebook.FNB_X_ON_TAB + +CLOSEABLE_PAGE_TYPE = Union[WorldManagerUI] + class AmuletMainWindow(wx.Frame): def __init__(self, parent): @@ -35,12 +41,15 @@ def __init__(self, parent): icon.CopyFromBitmap(wx.Bitmap(os.path.join(os.path.dirname(__file__), 'img', 'icon64.png'), wx.BITMAP_TYPE_ANY)) self.SetIcon(icon) - self._open_worlds: Dict[str, BaseWorldUI] = {} + self._open_worlds: Dict[str, CLOSEABLE_PAGE_TYPE] = {} - self.world_tab_holder = wx.Notebook( - self, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, 0 + self.world_tab_holder = flatnotebook.FlatNotebook( + self, + agwStyle=NOTEBOOK_MENU_STYLE ) + self.world_tab_holder.Bind(flatnotebook.EVT_FLATNOTEBOOK_PAGE_CLOSING, self._on_page_close) + self._main_menu = AmuletMainMenu(self.world_tab_holder, self._open_world) self._last_page: BaseWorldUI = self._main_menu @@ -50,7 +59,7 @@ def __init__(self, parent): lang.get('main_menu') ) - self.Bind(wx.EVT_CLOSE, self._on_close) + self.Bind(wx.EVT_CLOSE, self._on_close_app) self.Bind(wx.EVT_NOTEBOOK_PAGE_CHANGED, self._page_change) self.Show() @@ -104,28 +113,32 @@ def _disable_enable(self): if self._last_page is not None: self._last_page.disable() self._last_page: BaseWorldUI = current + if self._last_page is self._main_menu: + self.world_tab_holder.SetAGWWindowStyleFlag(NOTEBOOK_MENU_STYLE) + else: + self.world_tab_holder.SetAGWWindowStyleFlag(NOTEBOOK_STYLE) if self._last_page is not None: self._last_page.enable() def _add_world_tab(self, obj, obj_name): - # TODO: find a way for the tab to be optionally closeable self.world_tab_holder.AddPage(obj, obj_name, True) self._disable_enable() def _show_open_world(self): select_world = WorldSelectDialog(self, self._open_world) select_world.ShowModal() + select_world.Destroy() def _open_world(self, path: str): """Open a world panel add add it to the notebook""" if path in self._open_worlds: self.world_tab_holder.SetSelection( - self.world_tab_holder.FindPage(self._open_worlds[path]) + self.world_tab_holder.GetPageIndex(self._open_worlds[path]) ) self._disable_enable() else: try: - world = WorldManagerUI(self.world_tab_holder, path) + world = WorldManagerUI(self.world_tab_holder, path, lambda: self.close_world(path)) except LoaderNoneMatched as e: log.error(e) wx.MessageBox(str(e)) @@ -137,22 +150,30 @@ def close_world(self, path: str): """Close a given world and remove it from the notebook""" if path in self._open_worlds: world = self._open_worlds[path] - world.disable() - world.close() self.world_tab_holder.DeletePage( - self.world_tab_holder.FindPage(world) + self.world_tab_holder.GetPageIndex(world) ) - del self._open_worlds[path] - self._last_page: BaseWorldUI = self.world_tab_holder.GetCurrentPage() - if self._last_page is not None: - self._last_page.enable() - def _on_close(self, evt): + def _on_page_close(self, evt: flatnotebook.EVT_FLATNOTEBOOK_PAGE_CLOSING): + page: CLOSEABLE_PAGE_TYPE = self.world_tab_holder.GetCurrentPage() + if page is not self._main_menu: + if page.is_closeable(): + path = page.path + page.disable() + page.close() + self._last_page = None + del self._open_worlds[path] + else: + evt.Veto() + + def _on_close_app(self, evt): close = True - for path, world in list(self._open_worlds.items()): - if world.is_closeable(): + for path, page in list(self._open_worlds.items()): + page: CLOSEABLE_PAGE_TYPE + if page.is_closeable(): self.close_world(path) else: + log.info(f"{page.world_name} cannot be closed.") close = False if close: evt.Skip() @@ -162,14 +183,17 @@ def _on_close(self, evt): ) -class AmuletMainMenu(simple.SimplePanel, BaseWorldUI): - def __init__(self, parent, open_world): +class AmuletMainMenu(wx.Panel, BaseWorldUI): + def __init__(self, parent: wx.Window, open_world): super(AmuletMainMenu, self).__init__( parent ) + sizer = wx.BoxSizer(wx.VERTICAL) + self.SetSizer(sizer) + sizer.AddStretchSpacer(1) self._open_world_callback = open_world - sizer = wx.BoxSizer() - self.add_object(sizer, 0, wx.ALL | wx.CENTER) + name_sizer = wx.BoxSizer() + sizer.Add(name_sizer, 0, wx.CENTER) img = wx.Image( os.path.join(IMG_DIR, 'icon128.png'), wx.BITMAP_TYPE_ANY @@ -189,33 +213,37 @@ def __init__(self, parent, open_world): (0, 0), (64, 64) ) - sizer.Add(icon, flag=wx.CENTER) + icon2.Bind(wx.EVT_LEFT_DOWN, lambda evt: wx.lib.inspection.InspectionTool().Show()) + name_sizer.Add(icon, flag=wx.CENTER) amulet_converter = wx.StaticText(self, label='Amulet') amulet_converter.SetFont(wx.Font(40, wx.DECORATIVE, wx.NORMAL, wx.NORMAL)) - sizer.Add( + name_sizer.Add( amulet_converter, flag=wx.CENTER ) - sizer.Add(icon2, flag=wx.CENTER) + name_sizer.Add(icon2, flag=wx.CENTER) button_font = wx.Font(20, wx.DECORATIVE, wx.NORMAL, wx.NORMAL) self._open_world_button = wx.Button(self, label='Open World', size=(400, 70)) self._open_world_button.SetFont(button_font) self._open_world_button.Bind(wx.EVT_BUTTON, self._show_world_select) - self.add_object(self._open_world_button, 0, wx.ALL|wx.CENTER) + sizer.Add(self._open_world_button, 0, wx.ALL | wx.CENTER, 5) self._help_button = wx.Button(self, label='Help', size=(400, 70)) self._help_button.SetFont(button_font) self._help_button.Bind(wx.EVT_BUTTON, self._documentation) - self.add_object(self._help_button, 0, wx.ALL | wx.CENTER) + sizer.Add(self._help_button, 0, wx.ALL | wx.CENTER, 5) self._help_button = wx.Button(self, label='Amulet Discord', size=(400, 70)) self._help_button.SetFont(button_font) self._help_button.Bind(wx.EVT_BUTTON, self._discord) - self.add_object(self._help_button, 0, wx.ALL | wx.CENTER) + sizer.Add(self._help_button, 0, wx.ALL | wx.CENTER, 5) + + sizer.AddStretchSpacer(2) def _show_world_select(self, evt): select_world = WorldSelectDialog(self, self._open_world_callback) select_world.ShowModal() + select_world.Destroy() def _documentation(self, evt): webbrowser.open('https://github.com/Amulet-Team/Amulet-Map-Editor/blob/master/amulet_map_editor/readme.md') diff --git a/amulet_map_editor/programs/edit/ui/__init__.py b/amulet_map_editor/amulet_wx/ui/__init__.py similarity index 100% rename from amulet_map_editor/programs/edit/ui/__init__.py rename to amulet_map_editor/amulet_wx/ui/__init__.py diff --git a/amulet_map_editor/amulet_wx/block_select.py b/amulet_map_editor/amulet_wx/ui/select_block.py similarity index 85% rename from amulet_map_editor/amulet_wx/block_select.py rename to amulet_map_editor/amulet_wx/ui/select_block.py index 41e87419..6d331319 100644 --- a/amulet_map_editor/amulet_wx/block_select.py +++ b/amulet_map_editor/amulet_wx/ui/select_block.py @@ -1,4 +1,4 @@ -from amulet_map_editor.amulet_wx.simple import SimplePanel, SimpleText, SimpleChoice, SimpleChoiceAny +from amulet_map_editor.amulet_wx.ui.simple import SimpleChoice, SimpleChoiceAny import wx import PyMCTranslate import amulet_nbt @@ -6,17 +6,19 @@ from amulet.api.block import Block -class BaseSelect(SimplePanel): - def __init__(self, parent): - super().__init__(parent) +class BaseSelect(wx.Panel): + def __init__(self, parent, **kwargs): + super().__init__(parent, **kwargs) + self._sizer = wx.BoxSizer(wx.VERTICAL) + self.SetSizer(self._sizer) def _add_ui_element(self, label: str, obj: Type[wx.Control], **kwargs) -> Any: - panel = SimplePanel(self, wx.HORIZONTAL) - self.add_object(panel, 0) - text = SimpleText(panel, label) - panel.add_object(text, 0, wx.CENTER | wx.ALL) - wx_obj = obj(panel, **kwargs) - panel.add_object(wx_obj, 0, wx.CENTER | wx.ALL) + sizer = wx.BoxSizer(wx.HORIZONTAL) + self._sizer.Add(sizer, 0, wx.ALIGN_CENTER_HORIZONTAL) + text = wx.StaticText(self, label=label) + sizer.Add(text, 0, wx.CENTER | wx.LEFT | wx.RIGHT, 5) + wx_obj = obj(self, **kwargs) + sizer.Add(wx_obj, 0, wx.CENTER | wx.BOTTOM | wx.TOP | wx.RIGHT, 5) return wx_obj @@ -31,7 +33,7 @@ def __init__( allowed_platforms: Tuple[str, ...] = None, **kwargs ): - super().__init__(parent) + super().__init__(parent, **kwargs) self._translation_manager = translation_manager self._allow_universal = allow_universal self._allow_vanilla = allow_vanilla @@ -269,6 +271,7 @@ def __init__( base_name: str = None, properties: Dict[str, str] = None, wildcard: bool = False, + properties_style=0, **kwargs ): super().__init__( @@ -281,11 +284,13 @@ def __init__( base_name, **kwargs ) - self._properties: List[SimplePanel] = [] + self._properties: Dict[str, SimpleChoice] = {} self._wildcard = wildcard self._base_name_list.Bind(wx.EVT_CHOICE, self._on_base_name_change) - self._properties_panel: Optional[SimplePanel] = SimplePanel(self, wx.VERTICAL) - self.add_object(self._properties_panel, 0) + self._properties_panel: wx.Panel = wx.Panel(self, wx.VERTICAL, style=properties_style) + properties_sizer = wx.BoxSizer(wx.VERTICAL) + self._properties_panel.SetSizer(properties_sizer) + self._sizer.Add(self._properties_panel, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.BOTTOM, 5) self._set_properties(properties) @property @@ -295,8 +300,8 @@ def options(self) -> Tuple[str, Tuple[int, int, int], bool, str, str, Dict[str, @property def properties(self) -> Dict[str, str]: return { - prop.GetChildren()[0].GetLabel(): prop.GetChildren()[1].GetString(prop.GetChildren()[1].GetSelection()) - for prop in self._properties + property_name: ui.GetString(ui.GetSelection()) + for property_name, ui in self._properties.items() } @property @@ -311,19 +316,20 @@ def block(self) -> Block: ) def _clear_properties(self): - for prop in self._properties: - prop.Destroy() + for child in self._properties_panel.GetChildren(): + child.Destroy() self._properties.clear() self._properties_panel.Layout() def _add_property(self, property_name: str, property_values: List[str], default: str = None): - prop_panel = SimplePanel(self._properties_panel, wx.HORIZONTAL) - self._properties.append(prop_panel) - self._properties_panel.add_object(prop_panel, 0) - name_text = SimpleText(prop_panel, property_name) - prop_panel.add_object(name_text, 0, wx.CENTER | wx.ALL) - name_list = SimpleChoice(prop_panel) - prop_panel.add_object(name_list, 0, wx.CENTER | wx.ALL) + sizer = wx.BoxSizer(wx.HORIZONTAL) + self._properties_panel.GetSizer().Add(sizer, 0, wx.ALL, 5) + name_text = wx.StaticText(self._properties_panel, label=property_name) + sizer.Add(name_text, 0, wx.CENTER | wx.RIGHT, 5) + name_list = SimpleChoice(self._properties_panel) + sizer.Add(name_list, 0, wx.CENTER, 5) + + self._properties[property_name] = name_list if self._wildcard: property_values.insert(0, "*") @@ -349,6 +355,8 @@ def _set_properties(self, properties: Dict[str, str] = None): if 'properties' in specification: for prop, options in specification['properties'].items(): self._add_property(prop, options, properties.get(prop, None)) - self.Fit() - self.Layout() - self.GetTopLevelParent().Fit() + self.SetMinSize(self.GetSizer().CalcMin()) + parent = self.GetParent() # there may be a better way to do this + while parent is not None: + parent.Layout() + parent = parent.GetParent() diff --git a/amulet_map_editor/amulet_wx/world_select.py b/amulet_map_editor/amulet_wx/ui/select_world.py similarity index 99% rename from amulet_map_editor/amulet_wx/world_select.py rename to amulet_map_editor/amulet_wx/ui/select_world.py index d47b05b5..7ba2522b 100644 --- a/amulet_map_editor/amulet_wx/world_select.py +++ b/amulet_map_editor/amulet_wx/ui/select_world.py @@ -5,7 +5,7 @@ from typing import List, Dict, Tuple, Callable, TYPE_CHECKING from amulet_map_editor import lang, CONFIG from amulet import world_interface -from amulet_map_editor.amulet_wx import simple +from amulet_map_editor.amulet_wx.ui import simple from amulet_map_editor import log if TYPE_CHECKING: @@ -281,7 +281,7 @@ def __init__(self, parent, open_world_callback): wx.HORIZONTAL ) self._open_world_callback = open_world_callback - self.add_object(WorldSelectUI(self, self._update_recent), 2, wx.ALL | wx.EXPAND) + self.add_object(WorldSelectUI(self, self._update_recent), 1, wx.ALL | wx.EXPAND) self._recent_worlds = RecentWorldUI(self, self._update_recent) self.add_object(self._recent_worlds, 1, wx.EXPAND) diff --git a/amulet_map_editor/amulet_wx/simple.py b/amulet_map_editor/amulet_wx/ui/simple.py similarity index 90% rename from amulet_map_editor/amulet_wx/simple.py rename to amulet_map_editor/amulet_wx/ui/simple.py index 7659d93d..c32dccd5 100644 --- a/amulet_map_editor/amulet_wx/simple.py +++ b/amulet_map_editor/amulet_wx/ui/simple.py @@ -1,3 +1,6 @@ +"""A collection of classes for wx objects to abstract away +the repeated code and make working with wx a bit more simple.""" + import wx from wx.lib.scrolledpanel import ScrolledPanel from typing import Iterable, Union, Any, List, Optional, Sequence, Dict @@ -5,7 +8,7 @@ class SimpleSizer: def __init__(self, sizer_dir=wx.VERTICAL): - self.sizer = wx.BoxSizer(sizer_dir) + self._sizer = self.sizer = wx.BoxSizer(sizer_dir) def add_object(self, obj, space=1, options=wx.ALL): self.sizer.Add( @@ -31,6 +34,7 @@ def __init__(self, parent: wx.Window, sizer_dir=wx.VERTICAL): class SimpleScrollablePanel(ScrolledPanel, SimpleSizer): + """A scrolled panel that automatically sets itself up.""" def __init__(self, parent: wx.Window, sizer_dir=wx.VERTICAL, **kwargs): ScrolledPanel.__init__( self, @@ -43,19 +47,8 @@ def __init__(self, parent: wx.Window, sizer_dir=wx.VERTICAL, **kwargs): self.SetAutoLayout(1) -class SimpleText(wx.StaticText): - def __init__(self, parent: wx.Window, text): - super().__init__( - parent, - wx.ID_ANY, - text, - wx.DefaultPosition, - wx.DefaultSize, - 0 - ) - - class SimpleChoice(wx.Choice): + """A wrapper for wx.Choice that sets up the UI for you.""" def __init__(self, parent: wx.Window, choices: Sequence[str] = (), default: Optional[str] = None): super().__init__( parent, @@ -75,6 +68,7 @@ def GetCurrentString(self) -> str: class SimpleChoiceAny(wx.Choice): + """An extension for wx.Choice that enables showing and returning objects that are not strings.""" def __init__(self, parent: wx.Window, sort=True, reverse=False): super().__init__( parent @@ -127,6 +121,7 @@ def GetAny(self) -> Optional[Any]: class SimpleDialog(wx.Dialog): + """A dialog with ok and cancel buttons set up.""" def __init__(self, parent: wx.Window, title, sizer_dir=wx.VERTICAL): wx.Dialog.__init__( self, diff --git a/amulet_map_editor/programs/edit/ui/tool_options/__init__.py b/amulet_map_editor/amulet_wx/util/__init__.py similarity index 100% rename from amulet_map_editor/programs/edit/ui/tool_options/__init__.py rename to amulet_map_editor/amulet_wx/util/__init__.py diff --git a/amulet_map_editor/amulet_wx/icon.py b/amulet_map_editor/amulet_wx/util/icon.py similarity index 100% rename from amulet_map_editor/amulet_wx/icon.py rename to amulet_map_editor/amulet_wx/util/icon.py diff --git a/amulet_map_editor/amulet_wx/key_config.py b/amulet_map_editor/amulet_wx/util/key_config.py similarity index 97% rename from amulet_map_editor/amulet_wx/key_config.py rename to amulet_map_editor/amulet_wx/util/key_config.py index 5d8b6164..2fb5d53f 100644 --- a/amulet_map_editor/amulet_wx/key_config.py +++ b/amulet_map_editor/amulet_wx/util/key_config.py @@ -1,16 +1,17 @@ import wx -from amulet_map_editor.amulet_wx.simple import SimpleDialog, SimpleScrollablePanel, SimpleChoice +from amulet_map_editor.amulet_wx.ui.simple import SimpleDialog, SimpleScrollablePanel, SimpleChoice from typing import Dict, Tuple, Optional, Union, Sequence -from amulet_map_editor.amulet_wx.icon import ADD_ICON, SUBTRACT_ICON, EDIT_ICON +from amulet_map_editor.amulet_wx.util.icon import ADD_ICON, SUBTRACT_ICON, EDIT_ICON ModifierKeyType = str KeyType = Union[int, str] KeybindGroupIdType = str -KeybindIdType = str +KeyActionType = str ModifierType = Tuple[ModifierKeyType, ...] SerialisedKeyType = Tuple[ModifierType, KeyType] -KeybindGroup = Dict[KeybindIdType, SerialisedKeyType] +KeybindGroup = Dict[KeyActionType, SerialisedKeyType] +ActionLookupType = Dict[SerialisedKeyType, KeyActionType] KeybindContainer = Dict[KeybindGroupIdType, KeybindGroup] MouseLeft = "MOUSE_LEFT" @@ -287,7 +288,7 @@ def __init__( self, parent: wx.Window, selected_group: KeybindGroupIdType, - entries: Sequence[KeybindIdType], + entries: Sequence[KeyActionType], fixed_keybinds: KeybindContainer, user_keybinds: KeybindContainer ): @@ -311,7 +312,7 @@ def __init__( self, parent: wx.Window, selected_group: KeybindGroupIdType, - entries: Sequence[KeybindIdType], + entries: Sequence[KeyActionType], fixed_keybinds: KeybindContainer, user_keybinds: KeybindContainer ): diff --git a/amulet_map_editor/amulet_wx/util/validators.py b/amulet_map_editor/amulet_wx/util/validators.py new file mode 100644 index 00000000..7e6980d4 --- /dev/null +++ b/amulet_map_editor/amulet_wx/util/validators.py @@ -0,0 +1,49 @@ +import wx + + +SpecialChrs = { + wx.WXK_BACK, + wx.WXK_DELETE, + wx.WXK_SHIFT, + wx.WXK_END, + wx.WXK_HOME, + wx.WXK_LEFT, + wx.WXK_RIGHT, +} + + +class BaseValidator(wx.Validator): + """Validates data as it is entered into the text controls.""" + + def __init__(self): + super().__init__() + self.Bind(wx.EVT_CHAR, self.OnChar) + + def Clone(self): + return self.__class__() + + def Validate(self, win): + return True + + def TransferToWindow(self): + return True + + def TransferFromWindow(self): + return True + + def OnChar(self, event): + event.Skip() + + +class IntValidator(BaseValidator): + def OnChar(self, event): + keycode = int(event.GetKeyCode()) + if keycode in SpecialChrs or 48 <= keycode <= 57 or keycode == 45: + event.Skip() + + +class FloatValidator(BaseValidator): + def OnChar(self, event): + keycode = int(event.GetKeyCode()) + if keycode in SpecialChrs or 45 <= keycode <= 57: + event.Skip() diff --git a/amulet_map_editor/opengl/canvas/base.py b/amulet_map_editor/opengl/canvas/base.py index 5ab41eb0..64037ce6 100644 --- a/amulet_map_editor/opengl/canvas/base.py +++ b/amulet_map_editor/opengl/canvas/base.py @@ -5,6 +5,7 @@ import numpy import math from typing import Optional +from amulet_map_editor.opengl.data_types import CameraLocationType, CameraRotationType class BaseCanvas(glcanvas.GLCanvas): @@ -21,7 +22,6 @@ def __init__(self, parent: wx.Window): self._transformation_matrix: Optional[numpy.ndarray] = None def _setup_opengl(self): - glClearColor(0.5, 0.66, 1.0, 1.0) glEnable(GL_DEPTH_TEST) glEnable(GL_CULL_FACE) glDepthFunc(GL_LEQUAL) @@ -38,6 +38,14 @@ def _setup_opengl(self): def _close(self): glDeleteTextures([self._gl_texture_atlas]) + @property + def camera_location(self) -> CameraLocationType: + raise NotImplementedError + + @property + def camera_rotation(self) -> CameraRotationType: + raise NotImplementedError + @property def fov(self) -> float: return self._projection[0] @@ -107,9 +115,9 @@ def transformation_matrix(self) -> numpy.ndarray: # camera translation if self._transformation_matrix is None: transformation_matrix = numpy.eye(4, dtype=numpy.float32) - transformation_matrix[3, :3] = numpy.array(self._camera[:3]) * -1 + transformation_matrix[3, :3] = numpy.array(self.camera_location) * -1 - transformation_matrix = numpy.matmul(transformation_matrix, self.rotation_matrix(*self._camera[3:5])) + transformation_matrix = numpy.matmul(transformation_matrix, self.rotation_matrix(*self.camera_rotation)) self._transformation_matrix = numpy.matmul(transformation_matrix, self.projection_matrix()) return self._transformation_matrix diff --git a/amulet_map_editor/opengl/data_types.py b/amulet_map_editor/opengl/data_types.py new file mode 100644 index 00000000..48aded92 --- /dev/null +++ b/amulet_map_editor/opengl/data_types.py @@ -0,0 +1,4 @@ +from typing import Tuple, Union + +CameraLocationType = Tuple[Union[int, float], Union[int, float], Union[int, float]] +CameraRotationType = Tuple[Union[int, float], Union[int, float]] diff --git a/amulet_map_editor/opengl/mesh/selection.py b/amulet_map_editor/opengl/mesh/selection.py index abd17292..ad8cff8f 100644 --- a/amulet_map_editor/opengl/mesh/selection.py +++ b/amulet_map_editor/opengl/mesh/selection.py @@ -1,7 +1,7 @@ import numpy from OpenGL.GL import * import itertools -from typing import Tuple, Dict, Any, Optional, List +from typing import Tuple, Dict, Any, Optional, List, Union from amulet_map_editor.opengl.mesh.base.tri_mesh import TriMesh, Drawable from amulet.api.selection import SelectionGroup, SelectionBox @@ -34,7 +34,7 @@ def __iter__(self): if box.is_static: yield box - def __contains__(self, position: BlockCoordinatesAny): + def __contains__(self, position: Union[BlockCoordinatesAny, PointCoordinatesAny]): return any(position in box for box in self._boxes) def __getitem__(self, index: int) -> "RenderSelection": @@ -90,7 +90,7 @@ def delete_active(self): else: self._active = None - def update_position(self, position: PointCoordinatesAny, box_index: Optional[int]): + def update_position(self, position: BlockCoordinatesAny, box_index: Optional[int]): self._last_position = position self._hover_box = box_index self._temp_box.point1 = self._temp_box.point2 = position @@ -188,7 +188,7 @@ def being_resized(self) -> bool: """ return self._being_resized - def __contains__(self, position: BlockCoordinatesAny) -> bool: + def __contains__(self, position: Union[BlockCoordinatesAny, PointCoordinatesAny]) -> bool: """ Is the block position inside the selection box cuboid. :param position: (x, y, z) diff --git a/amulet_map_editor/opengl/mesh/world_renderer/world.py b/amulet_map_editor/opengl/mesh/world_renderer/world.py index e18c251f..b21e620e 100644 --- a/amulet_map_editor/opengl/mesh/world_renderer/world.py +++ b/amulet_map_editor/opengl/mesh/world_renderer/world.py @@ -8,17 +8,18 @@ import minecraft_model_reader import PyMCTranslate from amulet.api.block import BlockManager +from amulet.api.data_types import Dimension from amulet_map_editor import log from .chunk import RenderChunk from .region import ChunkManager +from amulet_map_editor.opengl.data_types import CameraLocationType, CameraRotationType from amulet_map_editor.opengl.resource_pack import ResourcePackManager from amulet_map_editor.opengl.mesh.base.tri_mesh import Drawable if TYPE_CHECKING: from amulet.api.world import World - def sin(theta: Union[int, float]) -> float: return math.sin(math.radians(theta)) @@ -137,8 +138,9 @@ def __init__( ): super().__init__(context_identifier, resource_pack, texture, texture_bounds, translator) self._world = world - self._camera = [0, 150, 0, 90, 0] - self._dimension = "overworld" + self._camera_location: CameraLocationType = (0, 150, 0) + self._camera_rotation: CameraRotationType = (90, 0) + self._dimension: Dimension = "overworld" self._render_distance = 10 self._garbage_distance = 20 self._chunk_manager = ChunkManager(self.context_identifier, self.texture) @@ -178,19 +180,27 @@ def close(self): self.disable() @property - def camera(self): - return self._camera + def camera_location(self) -> CameraLocationType: + return self._camera_location + + @camera_location.setter + def camera_location(self, value: CameraLocationType): + self._camera_location = value + + @property + def camera_rotation(self) -> CameraRotationType: + return self._camera_rotation - @camera.setter - def camera(self, value: List[Union[int, float]]): - self._camera = value + @camera_rotation.setter + def camera_rotation(self, value: CameraRotationType): + self._camera_rotation = value @property - def dimension(self) -> str: + def dimension(self) -> Dimension: return self._dimension @dimension.setter - def dimension(self, dimension: int): + def dimension(self, dimension: Dimension): self._chunk_generator.stop() self._dimension = dimension self.run_garbage_collector(True) @@ -218,7 +228,7 @@ def garbage_distance(self, val: int): def chunk_coords(self) -> Generator[Tuple[int, int], None, None]: """Get all of the chunks to draw/load""" - cx, cz = int(self._camera[0]) >> 4, int(self._camera[2]) >> 4 + cx, cz = int(self.camera_location[0]) >> 4, int(self.camera_location[2]) >> 4 sign = 1 length = 1 @@ -233,7 +243,7 @@ def chunk_coords(self) -> Generator[Tuple[int, int], None, None]: length += 1 def draw(self, transformation_matrix: numpy.ndarray): - self._chunk_manager.draw(transformation_matrix, self._camera[:3]) + self._chunk_manager.draw(transformation_matrix, self.camera_location) def run_garbage_collector(self, remove_all=False): if remove_all: @@ -242,10 +252,10 @@ def run_garbage_collector(self, remove_all=False): else: safe_area = ( self._dimension, - self._camera[0]//16 - self.garbage_distance, - self._camera[2]//16 - self.garbage_distance, - self._camera[0]//16 + self.garbage_distance, - self._camera[2]//16 + self.garbage_distance + self.camera_location[0]//16 - self.garbage_distance, + self.camera_location[2]//16 - self.garbage_distance, + self.camera_location[0]//16 + self.garbage_distance, + self.camera_location[2]//16 + self.garbage_distance ) self._chunk_manager.unload(safe_area[1:]) self._world.unload(safe_area) diff --git a/amulet_map_editor/plugins/__init__.py b/amulet_map_editor/plugins/__init__.py deleted file mode 100644 index ac0aaf3a..00000000 --- a/amulet_map_editor/plugins/__init__.py +++ /dev/null @@ -1,279 +0,0 @@ -""" ->>> # Example plugin ->>> from typing import List, Any, Callable, Dict, Optional ->>> from amulet.api.selection import SelectionGroup ->>> from amulet.api.structure import Structure ->>> from amulet.api.world import World ->>> WX_OBJ = Any ->>> Dimension = Any ->>> Options = Dict ->>> Destination = {"x": int, "y": int, "z": int} ->>> ->>> def show_ui(parent, world: World, options: dict) -> dict: # see below. Only needed if using wxoptions ->>> # create a UI and show it (probably best using wx.Dialog) ->>> # build the UI using the options that were returned last time (or an empty dictionary if it is the first) ->>> # and return the options how you wish ->>> ->>> export = { ->>> # required ->>> "v": 1, # a version 1 plugin ->>> "name": "Plugin Name", # the name of the plugin ->>> "features": List[str], # a list of features that enable functionality in the UI ->>> # the valid options are: (any invalid options will cause the operation to fail to load) ->>> "src_selection" # The user is required to select an area before running the plugin. They will be prompted if they do not. ->>> "dst_location_absolute" # This enables a UI to select a destination location. It also enables an optional callable and inputs at structure_callable. ->>> # If the callable is defined it will be run. If not the selection is extracted and used. ->>> # After the function has run the user will be shown a UI to pick a destination location for the Structure that was returned. ->>> # TODO: "options" # a simple system to create a UI to be shown to the user. Enables "options" key ->>> "wxoptions" # enables "wxoptions" key storing a callable allowing direct use of wx ->>> ->>> "options": dict, # requires "options" in features. A simple system of defining options from which a simple UI can be created ->>> "wxoptions": Callable[[WX_OBJ, World, Options], Options], # a more complex system allowing users to work directly with wx ->>> ->>> # if one of the dst_location options is enabled the following is valid ->>> "structure_callable_inputs": List[str], # see inputs below ->>> "structure_callable": Callable[[World, Dimension, ...], Structure] # World and Dimension are always the first two inputs. Above inputs follow. ->>> # this function is run before selecting the destination locations and returns a Structure for use there ->>> ->>> "inputs": List[str], # see inputs below ->>> "operation": Callable[[World, Dimension, ...], Optional[Any]], # the actual function to call when running the plugin ->>> # World and Dimension are always the first two inputs. Above inputs follow. ->>> } ->>> ->>> # Input format ->>> # a list of inputs to give to the plugin. World class and dimension are first and these follow ->>> # possible inputs (max one from each group) ->>> {"src_selection": SelectionGroup} # the user created selection ->>> { # requires a dst_location feature to be enabled. Only valid in main inputs ->>> "structure": Structure # an extracted Structure as returned by structure_callable or the area of the World selected by src_box ->>> } ->>> { ->>> "options": dict, # "options" or "wxoptions" feature must be enabled ->>> } -""" -import functools -import os -import glob -import importlib.util -from amulet import log -from typing import Dict, List - - -def _load_module_file(module_path: str): - spec = importlib.util.spec_from_file_location( - os.path.basename(module_path), module_path - ) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - -def _load_module_directory(module_path): - import sys - - spec = importlib.util.spec_from_file_location( - os.path.basename(os.path.dirname(module_path)), module_path - ) - mod = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = mod - spec.loader.exec_module(mod) - return mod - - -def _error(name, msg): - log.error(f"Error loading plugin {name}. {msg}") - - -def parse_export(plugin: dict, operations_dict: Dict[str, dict], path: str): - error = functools.partial(_error, path) - - if not isinstance(plugin, dict): - error("Export must be a dictionary.") - return - if not isinstance(plugin.get("name", None), str): - error('"name" in export must exist and be a string.') - return - structure_callable_options = ["options"] - input_options = ["options"] - dst_ui_enabled = False - features = plugin.get("features", []) - feature_index = 0 - stop = False - - while feature_index < len(features): - feature = features[feature_index] - feature_index += 1 - if feature == "src_selection": - structure_callable_options.append("src_selection") - input_options.append("src_selection") - elif feature == "wxoptions": - if "wxoptions" not in plugin: - error( - '"wxoptions" key must be defined if wxoptions feature is enabled.' - ) - stop = True - break - elif not callable(plugin["wxoptions"]): - error('"wxoptions" must be callable.') - stop = True - break - elif "options" in features: - error( - "Only one of options and wxoptions features may be enabled at once." - ) - stop = True - break - elif feature == "options": - if "options" not in plugin: - error('"options" key must be defined if options feature is enabled.') - stop = True - break - elif "wxoptions" in features: - error( - "Only one of options and wxoptions features may be enabled at once." - ) - stop = True - break - elif feature == "dst_location_absolute": - if dst_ui_enabled: - error("Only one dst_location feature can be enabled at once") - dst_ui_enabled = True - input_options.append("dst_location") - input_options.append("structure") - if stop: - return - - if dst_ui_enabled: - if "structure_callable" in plugin: - if not callable(plugin["structure_callable"]): - error('"structure_callable" must be a callable if defined.') - return - structure_callable_enabled = True - else: - if "src_selection" not in features: - features.append("src_selection") - structure_callable_enabled = False - else: - structure_callable_enabled = False - - plugin_operations = [ - ( - "operation", - plugin.get("operation", None), - plugin.get("inputs", []), - input_options, - ) - ] - if structure_callable_enabled: - plugin_operations.append( - ( - "structure_callable", - plugin.get("structure_callable", None), - plugin.get("structure_callable_inputs", []), - structure_callable_options, - ) - ) - for name, operation, inputs, options in plugin_operations: - if not callable(operation): - error(f'"{name}" in export must exist and be a function.') - stop = True - return - if not isinstance(inputs, list): - error(f'"{name}" inputs in export must be a list.') - stop = True - return - for inp in inputs: - if inp not in options: - error(f'"{inp}" is not a supported input for "{name}"') - stop = True - return - if stop: - return - - operations_dict[path] = plugin - - -def _load_operations(operations_: Dict[str, dict], path: str): - """load all operations from a specified directory""" - if os.path.isdir(path): - for fpath in glob.iglob(os.path.join(path, "*.py")): - if fpath.endswith("__init__.py"): - continue - - mod = _load_module_file(fpath) - if hasattr(mod, "export"): - plugin = getattr(mod, "export") - parse_export(plugin, operations_, fpath) - elif hasattr(mod, "exports"): - for plugin in getattr(mod, "exports"): - parse_export(plugin, operations_, fpath) - - for dpath in glob.iglob(os.path.join(path, "**", "__init__.py")): - mod = _load_module_directory(dpath) - if hasattr(mod, "export"): - plugin = getattr(mod, "export") - parse_export( - plugin, operations_, os.path.basename(os.path.dirname(dpath)) - ) - elif hasattr(mod, "exports"): - for plugin in getattr(mod, "exports"): - parse_export( - plugin, operations_, os.path.basename(os.path.dirname(dpath)) - ) - - -def _load_operations_group(dir_paths: List[str]): - """Load operations from a list of directory paths""" - operations_: Dict[str, dict] = {} - for dir_path in dir_paths: - _load_operations(operations_, dir_path) - return operations_ - - -all_operations: Dict[str, dict] = {} -internal_operations: Dict[str, dict] = {} -operations: Dict[str, dict] = {} -export_operations: Dict[str, dict] = {} -import_operations: Dict[str, dict] = {} -_meta: Dict[str, Dict[str, dict]] = { - 'internal_operations': internal_operations, - 'operations': operations, - 'export_operations': export_operations, - 'import_operations': import_operations -} -_public = { - 'operations', - 'export_operations', - 'import_operations' -} - -options: Dict[str, dict] = {} - - -def merge_operations(): - """Merge all loaded operations into all_operations""" - all_operations.clear() - for ops in _meta.values(): - all_operations.update(ops) - - -def _reload_operation_name(dir_name): - """reload all operations in a directory name.""" - if dir_name in _public: - os.makedirs(os.path.join("plugins", dir_name), exist_ok=True) - _meta[dir_name].clear() - name_operations = _load_operations_group( - [os.path.join(os.path.dirname(__file__), dir_name)] + - [os.path.join("plugins", dir_name)] * (dir_name in _public) - ) - _meta[dir_name].update(name_operations) - - -def reload_operations(): - """Reload all operations""" - for dir_name in _meta.keys(): - _reload_operation_name(dir_name) - merge_operations() - - -reload_operations() diff --git a/amulet_map_editor/plugins/export_operations/construction.py b/amulet_map_editor/plugins/export_operations/construction.py deleted file mode 100644 index 80a69faa..00000000 --- a/amulet_map_editor/plugins/export_operations/construction.py +++ /dev/null @@ -1,75 +0,0 @@ -from typing import TYPE_CHECKING -import wx - -from amulet_map_editor.amulet_wx.block_select import VersionSelect -from amulet_map_editor.amulet_wx.simple import SimpleDialog -from amulet.api.selection import SelectionGroup -from amulet.api.errors import ChunkLoadError -from amulet.api.data_types import Dimension -from amulet.structure_interface.construction import ConstructionFormatWrapper - -if TYPE_CHECKING: - from amulet.api.world import World - - -def export_construction( - world: "World", - dimension: Dimension, - selection: SelectionGroup, - options: dict -): - path, platform, version = options.get('path', None), options.get('platform', None), options.get('version', None) - if isinstance(path, str) and path.endswith('.construction') and platform and version: - wrapper = ConstructionFormatWrapper(path, 'w') - wrapper.platform = platform - wrapper.version = version - wrapper.selection = selection - wrapper.translation_manager = world.translation_manager - wrapper.open() - for cx, cz in selection.chunk_locations(): - try: - chunk = world.get_chunk(cx, cz, dimension) - wrapper.commit_chunk(chunk, world.palette) - except ChunkLoadError: - continue - - wrapper.close() - else: - raise Exception('Please specify a save location and version in the options before running.') - - -def show_ui(parent, world: "World", options: dict) -> dict: - dialog = SimpleDialog(parent, 'Export Construction') - file_picker = wx.FilePickerCtrl( - dialog, - path=options.get('path', ''), - wildcard="Construction file (*.construction)|*.construction", - style=wx.FLP_USE_TEXTCTRL | wx.FLP_SAVE | wx.FLP_OVERWRITE_PROMPT - ) - dialog.sizer.Add(file_picker, 0, wx.ALL, 5) - version_define = VersionSelect( - dialog, - world.translation_manager, - options.get("platform", None) or world.world_wrapper.platform, - allow_universal=False - ) - dialog.sizer.Add(version_define, 0) - dialog.Fit() - - if dialog.ShowModal() == wx.ID_OK: - options = { - "path": file_picker.GetPath(), - "platform": version_define.platform, - "version": version_define.version - } - return options - - -export = { - "v": 1, # a version 1 plugin - "name": "Export Construction", # the name of the plugin - "features": ["src_selection", "wxoptions"], - "inputs": ["src_selection", "options"], # the inputs to give to the plugin - "operation": export_construction, # the actual function to call when running the plugin - "wxoptions": show_ui -} diff --git a/amulet_map_editor/plugins/export_operations/mcstructure.py b/amulet_map_editor/plugins/export_operations/mcstructure.py deleted file mode 100644 index 9b2bc671..00000000 --- a/amulet_map_editor/plugins/export_operations/mcstructure.py +++ /dev/null @@ -1,75 +0,0 @@ -from typing import TYPE_CHECKING -import wx - -from amulet_map_editor.amulet_wx.block_select import VersionSelect -from amulet_map_editor.amulet_wx.simple import SimpleDialog -from amulet.api.selection import SelectionGroup -from amulet.api.errors import ChunkLoadError -from amulet.api.data_types import Dimension -from amulet.structure_interface.mcstructure import MCStructureFormatWrapper - -if TYPE_CHECKING: - from amulet.api.world import World - - -def export_mcstructure( - world: "World", - dimension: Dimension, - selection: SelectionGroup, - options: dict -): - assert len(selection.selection_boxes) == 1, "The mcstructure format only supports a single selection box." - path, version = options.get('path', None), options.get('version', None) - if isinstance(path, str) and path.endswith('.mcstructure') and version: - wrapper = MCStructureFormatWrapper(path, 'w') - wrapper.selection = selection - wrapper.version = version - wrapper.translation_manager = world.translation_manager - wrapper.open() - for cx, cz in wrapper.selection.chunk_locations(): - try: - chunk = world.get_chunk(cx, cz, dimension) - wrapper.commit_chunk(chunk, world.palette) - except ChunkLoadError: - continue - - wrapper.close() - else: - raise Exception('Please specify a save location and platform in the options before running.') - - -def show_ui(parent, world: "World", options: dict) -> dict: - dialog = SimpleDialog(parent, 'Export Bedrock .mcstructure') - file_picker = wx.FilePickerCtrl( - dialog, - path=options.get('path', ''), - wildcard="mcstructure file (*.mcstructure)|*.mcstructure", - style=wx.FLP_USE_TEXTCTRL | wx.FLP_SAVE | wx.FLP_OVERWRITE_PROMPT - ) - dialog.sizer.Add(file_picker, 0, wx.ALL, 5) - version_define = VersionSelect( - dialog, - world.translation_manager, - options.get("platform", None) or world.world_wrapper.platform, - allowed_platforms=("bedrock", ), - allow_numerical=False - ) - dialog.sizer.Add(version_define, 0) - dialog.Fit() - - if dialog.ShowModal() == wx.ID_OK: - options = { - "path": file_picker.GetPath(), - "version": version_define.version - } - return options - - -export = { - "v": 1, # a version 1 plugin - "name": "Export Bedrock .mcstructure", # the name of the plugin - "features": ["src_selection", "wxoptions"], - "inputs": ["src_selection", "options"], # the inputs to give to the plugin - "operation": export_mcstructure, # the actual function to call when running the plugin - "wxoptions": show_ui -} diff --git a/amulet_map_editor/plugins/export_operations/schematic.py b/amulet_map_editor/plugins/export_operations/schematic.py deleted file mode 100644 index dab139a3..00000000 --- a/amulet_map_editor/plugins/export_operations/schematic.py +++ /dev/null @@ -1,90 +0,0 @@ -from typing import TYPE_CHECKING -import wx - -from amulet_map_editor.amulet_wx.block_select import PlatformSelect -from amulet_map_editor.amulet_wx.simple import SimpleDialog -from amulet.api.selection import SelectionGroup -from amulet.api.errors import ChunkLoadError -from amulet.api.data_types import Dimension -from amulet.structure_interface.schematic import SchematicFormatWrapper - -if TYPE_CHECKING: - from amulet.api.world import World - - -def export_schematic( - world: "World", - dimension: Dimension, - selection: SelectionGroup, - options: dict -): - assert len(selection.selection_boxes) == 1, "The schematic format only supports a single selection box." - path, platform = options.get('path', None), options.get('platform', None) - if isinstance(path, str) and path.endswith('.schematic') and platform: - wrapper = SchematicFormatWrapper(path, 'w') - wrapper.platform = platform - wrapper.selection = selection - wrapper.translation_manager = world.translation_manager - wrapper.open() - for cx, cz in wrapper.selection.chunk_locations(): - try: - chunk = world.get_chunk(cx, cz, dimension) - wrapper.commit_chunk(chunk, world.palette) - except ChunkLoadError: - continue - - wrapper.close() - else: - raise Exception('Please specify a save location and platform in the options before running.') - - -def show_ui(parent, world: "World", options: dict) -> dict: - dialog = SimpleDialog(parent, 'Export Schematic (legacy)') - file_picker = wx.FilePickerCtrl( - dialog, - path=options.get('path', ''), - wildcard="Schematic file (*.schematic)|*.schematic", - style=wx.FLP_USE_TEXTCTRL | wx.FLP_SAVE | wx.FLP_OVERWRITE_PROMPT - ) - dialog.sizer.Add(file_picker, 0, wx.ALL | wx.CENTER, 5) - platform_define = PlatformSelect( - dialog, - world.translation_manager, - options.get("platform", None) or world.world_wrapper.platform, - allow_universal=False - ) - dialog.sizer.Add(platform_define, 0, wx.CENTER, 5) - dialog.sizer.Add( - wx.StaticText( - dialog, - label="""The Schematic format is a -legacy format that can only -save data in the numerical -format. Anything that was -added to the game in version -1.13 or after will not be -saved in the schematic file. - -We suggest using the Construction -file format instead.""", - style=wx.ALIGN_CENTRE_HORIZONTAL - ), 0, wx.ALL | wx.CENTER, 5 - ) - dialog.Fit() - - if dialog.ShowModal() == wx.ID_OK: - options = { - "path": file_picker.GetPath(), - "platform": platform_define.platform - } - return options - - -export = { - "v": 1, # a version 1 plugin - "name": "Export Schematic (legacy)", # the name of the plugin - "features": ["src_selection", "wxoptions"], - "inputs": ["src_selection", "options"], # the inputs to give to the plugin - "operation": export_schematic, # the actual function to call when running the plugin - "wxoptions": show_ui -} diff --git a/amulet_map_editor/plugins/import_operations/construction.py b/amulet_map_editor/plugins/import_operations/construction.py deleted file mode 100644 index 9e298206..00000000 --- a/amulet_map_editor/plugins/import_operations/construction.py +++ /dev/null @@ -1,74 +0,0 @@ -from typing import TYPE_CHECKING -import wx - -import os -from amulet_map_editor.amulet_wx.simple import SimpleDialog -from amulet.api.block import BlockManager -from amulet.api.errors import ChunkLoadError -from amulet.api.data_types import Dimension -from amulet.api.structure import Structure -from amulet.structure_interface.construction import ConstructionFormatWrapper -from amulet.operations.paste import paste - -if TYPE_CHECKING: - from amulet.api.world import World - - -def show_ui(parent, world: "World", options: dict) -> dict: - dialog = SimpleDialog(parent, 'Import Construction') - file_picker = wx.FilePickerCtrl( - dialog, - path=options.get('path', ''), - wildcard="Construction file (*.construction)|*.construction", - style=wx.FLP_USE_TEXTCTRL | wx.FLP_OPEN - ) - dialog.sizer.Add(file_picker, 0, wx.ALL, 5) - dialog.Fit() - - if dialog.ShowModal() == wx.ID_OK: - options = { - "path": file_picker.GetPath() - } - return options - - -def import_construction( - world: "World", - dimension: Dimension, - options: dict -) -> Structure: - path = options.get('path', None) - if isinstance(path, str) and path.endswith('.construction') and os.path.isfile(path): - wrapper = ConstructionFormatWrapper(path, 'r') - wrapper.translation_manager = world.translation_manager - wrapper.open() - selection = wrapper.selection - - global_palette = BlockManager() - chunks = {} - for (cx, cz) in wrapper.all_chunk_coords(): - try: - chunks[(cx, cz)] = wrapper.load_chunk(cx, cz, global_palette) - except ChunkLoadError: - pass - - wrapper.close() - return Structure( - chunks, - global_palette, - selection - ) - else: - raise Exception('Please specify a construction file in the options before running.') - - -export = { - "v": 1, # a version 1 plugin - "name": "Import Construction", # the name of the plugin - "features": ["src_selection", "wxoptions", "dst_location_absolute"], - "structure_callable_inputs": ["options"], # the inputs to give to the plugin - "structure_callable": import_construction, - "inputs": ["structure", "options"], # the inputs to give to the plugin - "operation": paste, # the actual function to call when running the plugin - "wxoptions": show_ui -} diff --git a/amulet_map_editor/plugins/import_operations/mcstructure.py b/amulet_map_editor/plugins/import_operations/mcstructure.py deleted file mode 100644 index f2f3945f..00000000 --- a/amulet_map_editor/plugins/import_operations/mcstructure.py +++ /dev/null @@ -1,74 +0,0 @@ -from typing import TYPE_CHECKING -import wx - -import os -from amulet_map_editor.amulet_wx.simple import SimpleDialog -from amulet.api.block import BlockManager -from amulet.api.errors import ChunkLoadError -from amulet.api.data_types import Dimension -from amulet.api.structure import Structure -from amulet.structure_interface.mcstructure import MCStructureFormatWrapper -from amulet.operations.paste import paste - -if TYPE_CHECKING: - from amulet.api.world import World - - -def show_ui(parent, world: "World", options: dict) -> dict: - dialog = SimpleDialog(parent, 'Import Bedrock .mcstructure') - file_picker = wx.FilePickerCtrl( - dialog, - path=options.get('path', ''), - wildcard="Bedrock mcstructure file (*.mcstructure)|*.mcstructure", - style=wx.FLP_USE_TEXTCTRL | wx.FLP_OPEN - ) - dialog.sizer.Add(file_picker, 0, wx.ALL, 5) - dialog.Fit() - - if dialog.ShowModal() == wx.ID_OK: - options = { - "path": file_picker.GetPath() - } - return options - - -def import_schematic( - world: "World", - dimension: Dimension, - options: dict -) -> Structure: - path = options.get('path', None) - if isinstance(path, str) and path.endswith('.mcstructure') and os.path.isfile(path): - wrapper = MCStructureFormatWrapper(path, 'r') - wrapper.translation_manager = world.translation_manager - wrapper.open() - selection = wrapper.selection - - global_palette = BlockManager() - chunks = {} - for (cx, cz) in wrapper.all_chunk_coords(): - try: - chunks[(cx, cz)] = wrapper.load_chunk(cx, cz, global_palette) - except ChunkLoadError: - pass - - wrapper.close() - return Structure( - chunks, - global_palette, - selection - ) - else: - raise Exception('Please specify a mcstructure file in the options before running.') - - -export = { - "v": 1, # a version 1 plugin - "name": "Import Bedrock .mcstructure", # the name of the plugin - "features": ["src_selection", "wxoptions", "dst_location_absolute"], - "structure_callable_inputs": ["options"], # the inputs to give to the plugin - "structure_callable": import_schematic, - "inputs": ["structure", "options"], # the inputs to give to the plugin - "operation": paste, # the actual function to call when running the plugin - "wxoptions": show_ui -} diff --git a/amulet_map_editor/plugins/import_operations/schematic.py b/amulet_map_editor/plugins/import_operations/schematic.py deleted file mode 100644 index 48e4e0b8..00000000 --- a/amulet_map_editor/plugins/import_operations/schematic.py +++ /dev/null @@ -1,74 +0,0 @@ -from typing import TYPE_CHECKING -import wx - -import os -from amulet_map_editor.amulet_wx.simple import SimpleDialog -from amulet.api.block import BlockManager -from amulet.api.errors import ChunkLoadError -from amulet.api.data_types import Dimension -from amulet.api.structure import Structure -from amulet.structure_interface.schematic import SchematicFormatWrapper -from amulet.operations.paste import paste - -if TYPE_CHECKING: - from amulet.api.world import World - - -def show_ui(parent, world: "World", options: dict) -> dict: - dialog = SimpleDialog(parent, 'Import Schematic (legacy)') - file_picker = wx.FilePickerCtrl( - dialog, - path=options.get('path', ''), - wildcard="Schematic file (*.schematic)|*.schematic", - style=wx.FLP_USE_TEXTCTRL | wx.FLP_OPEN - ) - dialog.sizer.Add(file_picker, 0, wx.ALL, 5) - dialog.Fit() - - if dialog.ShowModal() == wx.ID_OK: - options = { - "path": file_picker.GetPath() - } - return options - - -def import_schematic( - world: "World", - dimension: Dimension, - options: dict -) -> Structure: - path = options.get('path', None) - if isinstance(path, str) and path.endswith('.schematic') and os.path.isfile(path): - wrapper = SchematicFormatWrapper(path, 'r') - wrapper.translation_manager = world.translation_manager - wrapper.open() - selection = wrapper.selection - - global_palette = BlockManager() - chunks = {} - for (cx, cz) in wrapper.all_chunk_coords(): - try: - chunks[(cx, cz)] = wrapper.load_chunk(cx, cz, global_palette) - except ChunkLoadError: - pass - - wrapper.close() - return Structure( - chunks, - global_palette, - selection - ) - else: - raise Exception('Please specify a schematic file in the options before running.') - - -export = { - "v": 1, # a version 1 plugin - "name": "Import Schematic (legacy)", # the name of the plugin - "features": ["src_selection", "wxoptions", "dst_location_absolute"], - "structure_callable_inputs": ["options"], # the inputs to give to the plugin - "structure_callable": import_schematic, - "inputs": ["structure", "options"], # the inputs to give to the plugin - "operation": paste, # the actual function to call when running the plugin - "wxoptions": show_ui -} diff --git a/amulet_map_editor/plugins/internal_operations/copy.py b/amulet_map_editor/plugins/internal_operations/copy.py deleted file mode 100644 index 88e762cf..00000000 --- a/amulet_map_editor/plugins/internal_operations/copy.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import TYPE_CHECKING - -from amulet.api.structure import structure_buffer, Structure -from amulet.api.data_types import Dimension -from amulet.api.selection import SelectionGroup - -if TYPE_CHECKING: - from amulet.api.world import World - - -def copy_selection( - world: "World", - dimension: Dimension, - selection: SelectionGroup -): - structure = Structure.from_world( - world, selection, dimension - ) - structure_buffer.append(structure) - return True - - -export = { - "v": 1, # a version 1 plugin - "name": "Copy", # the name of the plugin - "features": ["src_selection"], - "inputs": ["src_selection"], # the inputs to give to the plugin - "operation": copy_selection # the actual function to call when running the plugin -} diff --git a/amulet_map_editor/plugins/internal_operations/cut.py b/amulet_map_editor/plugins/internal_operations/cut.py deleted file mode 100644 index dc0a9d19..00000000 --- a/amulet_map_editor/plugins/internal_operations/cut.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import TYPE_CHECKING - -from amulet.api.structure import structure_buffer, Structure -from amulet.api.data_types import Dimension -from amulet.api.selection import SelectionGroup -from amulet.operations.fill import fill -from amulet.api.block import Block - -if TYPE_CHECKING: - from amulet.api.world import World - - -def copy_selection( - world: "World", - dimension: Dimension, - selection: SelectionGroup -): - structure = Structure.from_world( - world, selection, dimension - ) - structure_buffer.append(structure) - yield from fill( - world, - dimension, - selection, - { - "fill_block": world.translation_manager.get_version( - 'java', (1, 15, 2) - ).block.to_universal( - Block("minecraft", "air") - )[0] - } - ) - - -export = { - "v": 1, # a version 1 plugin - "name": "Copy", # the name of the plugin - "features": ["src_selection"], - "inputs": ["src_selection"], # the inputs to give to the plugin - "operation": copy_selection # the actual function to call when running the plugin -} diff --git a/amulet_map_editor/plugins/internal_operations/delete.py b/amulet_map_editor/plugins/internal_operations/delete.py deleted file mode 100644 index 0f7ee0e6..00000000 --- a/amulet_map_editor/plugins/internal_operations/delete.py +++ /dev/null @@ -1,37 +0,0 @@ -from typing import TYPE_CHECKING - -from amulet.operations.fill import fill -from amulet.api.selection import SelectionGroup -from amulet.api.block import Block -from amulet.api.data_types import Dimension - -if TYPE_CHECKING: - from amulet.api.world import World - - -def delete( - world: "World", - dimension: Dimension, - selection: SelectionGroup -): - yield from fill( - world, - dimension, - selection, - { - "fill_block": world.translation_manager.get_version( - 'java', (1, 15, 2) - ).block.to_universal( - Block("minecraft", "air") - )[0] - } - ) - - -export = { - "v": 1, # a version 1 plugin - "name": "Delete", # the name of the plugin - "features": ["src_selection"], - "inputs": ["src_selection"], # the inputs to give to the plugin - "operation": delete, # the actual function to call when running the plugin -} diff --git a/amulet_map_editor/plugins/internal_operations/paste.py b/amulet_map_editor/plugins/internal_operations/paste.py deleted file mode 100644 index 4b3ebd1f..00000000 --- a/amulet_map_editor/plugins/internal_operations/paste.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import TYPE_CHECKING -from amulet.operations.paste import paste - -from amulet.api.structure import structure_buffer, Structure -from amulet.api.data_types import Dimension - -if TYPE_CHECKING: - from amulet.api.world import World - - -def get_structure( - world: "World", - dimension: Dimension, -) -> Structure: - if structure_buffer: - return structure_buffer[-1] - else: - raise Exception('You need to copy a selection before pasting.') - - -export = { - "v": 1, # a version 1 plugin - "name": "Paste", # the name of the plugin - "features": ["dst_location_absolute"], - "structure_callable_inputs": [], - "structure_callable": get_structure, - "inputs": ["structure", "options"], # the inputs to give to the plugin - "operation": paste # the actual function to call when running the plugin -} diff --git a/amulet_map_editor/plugins/operations/clone.py b/amulet_map_editor/plugins/operations/clone.py deleted file mode 100644 index 18dc74e8..00000000 --- a/amulet_map_editor/plugins/operations/clone.py +++ /dev/null @@ -1,9 +0,0 @@ -from amulet.operations.paste import paste - -export = { - "v": 1, # a version 1 plugin - "name": "Clone", # the name of the plugin - "features": ["src_selection", "dst_location_absolute"], - "inputs": ["structure", "options"], # the inputs to give to the plugin - "operation": paste # the actual function to call when running the plugin -} diff --git a/amulet_map_editor/plugins/operations/delete_chunk.py b/amulet_map_editor/plugins/operations/delete_chunk.py deleted file mode 100644 index d689c981..00000000 --- a/amulet_map_editor/plugins/operations/delete_chunk.py +++ /dev/null @@ -1,9 +0,0 @@ -from amulet.operations.delete_chunk import delete_chunk - -export = { - "v": 1, # a version 1 plugin - "name": "Delete Chunks", # the name of the plugin - "features": ["src_selection"], - "inputs": ["src_selection"], # the inputs to give to the plugin - "operation": delete_chunk # the actual function to call when running the plugin -} diff --git a/amulet_map_editor/plugins/operations/fill.py b/amulet_map_editor/plugins/operations/fill.py deleted file mode 100644 index fd6652d8..00000000 --- a/amulet_map_editor/plugins/operations/fill.py +++ /dev/null @@ -1,57 +0,0 @@ -from typing import TYPE_CHECKING -import wx - -from amulet.operations.fill import fill -from amulet.api.selection import SelectionGroup -from amulet.api.block import Block -from amulet.api.data_types import Dimension -from amulet_map_editor.amulet_wx.block_select import BlockDefine -from amulet_map_editor.amulet_wx.simple import SimpleDialog - -if TYPE_CHECKING: - from amulet.api.world import World - - -def fill_( - world: "World", - dimension: Dimension, - selection: SelectionGroup, - options: dict -): - if isinstance(options.get('fill_block'), Block): - yield from fill(world, dimension, selection, options) - else: - raise Exception('Please specify a block before running the fill operation') - - -def show_ui(parent, world: "World", options: dict) -> dict: - dialog = SimpleDialog(parent, 'Fill') - block_define = BlockDefine( - dialog, - world.world_wrapper.translation_manager, - *(options.get("fill_block_options", []) or [world.world_wrapper.platform]) - ) - dialog.sizer.Add(block_define, 1) - dialog.Fit() - if dialog.ShowModal() == wx.ID_OK: - options = { - "fill_block": world.world_wrapper.translation_manager.get_version( - block_define.platform, - block_define.version - ).block.to_universal( - block_define.block, - force_blockstate=block_define.force_blockstate - )[0], - "fill_block_options": block_define.options - } - return options - - -export = { - "v": 1, # a version 1 plugin - "name": "Fill", # the name of the plugin - "features": ["src_selection", "wxoptions"], - "inputs": ["src_selection", "options"], # the inputs to give to the plugin - "operation": fill_, # the actual function to call when running the plugin - "wxoptions": show_ui -} diff --git a/amulet_map_editor/plugins/operations/replace.py b/amulet_map_editor/plugins/operations/replace.py deleted file mode 100644 index ee921cf8..00000000 --- a/amulet_map_editor/plugins/operations/replace.py +++ /dev/null @@ -1,101 +0,0 @@ -from typing import TYPE_CHECKING, Tuple, Dict -import wx -import numpy - -from amulet.api.block import Block -from amulet.api.selection import SelectionGroup -from amulet.api.data_types import Dimension -from amulet_map_editor.amulet_wx.block_select import BlockDefine -from amulet_map_editor.amulet_wx.simple import SimpleDialog - -if TYPE_CHECKING: - from amulet.api.world import World - - -def replace( - world: "World", - dimension: Dimension, - selection: SelectionGroup, - options: dict -): - original_block_options: Tuple[str, Tuple[int, int, int], bool, str, str, Dict[str, str]] = options.get("original_block_options") - replacement_block: Block = options.get("replacement_block") - if original_block_options is None or not isinstance(replacement_block, Block): - # verify that the options are actually given - raise Exception('Please specify the blocks before running the replace operation') - - original_platform, original_version, original_blockstate, original_namespace, original_base_name, original_properties = original_block_options - replacement_block_id = world.palette.get_add_block(replacement_block) - - original_block_matches = [] - universal_block_count = 0 - - iter_count = len(list(world.get_chunk_slices(selection, dimension))) - count = 0 - - for chunk, slices, _ in world.get_chunk_slices(selection, dimension): - if universal_block_count < len(world.palette): - for universal_block_id in range(universal_block_count, len(world.palette)): - version_block = world.translation_manager.get_version( - original_platform, - original_version - ).block.from_universal( - world.palette[universal_block_id], - force_blockstate=original_blockstate - )[0] - if version_block.namespace == original_namespace and \ - version_block.base_name == original_base_name\ - and all(original_properties.get(prop) in ['*', val.to_snbt()] for prop, val in version_block.properties.items()): - original_block_matches.append(universal_block_id) - - universal_block_count = len(world.palette) - blocks = chunk.blocks[slices] - blocks[numpy.isin(blocks, original_block_matches)] = replacement_block_id - chunk.blocks[slices] = blocks - chunk.changed = True - - count += 1 - yield 100 * count / iter_count - - -def show_ui(parent, world: "World", options: dict) -> dict: - dialog = SimpleDialog(parent, 'Replace', wx.HORIZONTAL) - - original_block = BlockDefine( - dialog, - world.world_wrapper.translation_manager, - *(options.get("original_block_options", []) or [world.world_wrapper.platform]), - wildcard=True - ) - replacement_block = BlockDefine( - dialog, - world.world_wrapper.translation_manager, - *(options.get("replacement_block_options", []) or [world.world_wrapper.platform]) - ) - dialog.sizer.Add(original_block, 0) - dialog.sizer.Add(replacement_block, 0) - dialog.Fit() - - if dialog.ShowModal() == wx.ID_OK: - options = { - "original_block_options": original_block.options, - "replacement_block": world.translation_manager.get_version( - replacement_block.platform, - replacement_block.version - ).block.to_universal( - replacement_block.block, - force_blockstate=replacement_block.force_blockstate - )[0], - "replacement_block_options": replacement_block.options, - } - return options - - -export = { - "v": 1, # a version 1 plugin - "name": "Replace", # the name of the plugin - "features": ["src_selection", "wxoptions"], - "inputs": ["src_selection", "options"], # the inputs to give to the plugin - "operation": replace, # the actual function to call when running the plugin - "wxoptions": show_ui -} diff --git a/amulet_map_editor/plugins/operations/waterlog.py b/amulet_map_editor/plugins/operations/waterlog.py deleted file mode 100644 index de65878f..00000000 --- a/amulet_map_editor/plugins/operations/waterlog.py +++ /dev/null @@ -1,78 +0,0 @@ -from typing import TYPE_CHECKING -import wx -import numpy - -from amulet.api.selection import SelectionGroup -from amulet.api.block import Block -from amulet.api.data_types import Dimension -from amulet_map_editor.amulet_wx.block_select import BlockDefine -from amulet_map_editor.amulet_wx.simple import SimpleDialog - -if TYPE_CHECKING: - from amulet.api.world import World - - -def waterlog( - world: "World", - dimension: Dimension, - selection: SelectionGroup, - options: dict -): - waterlog_block = options.get("fill_block", None) - waterlog_block: Block - if isinstance(waterlog_block, Block): - iter_count = len(list(world.get_chunk_slices(selection, dimension, True))) - count = 0 - for chunk, slices, _ in world.get_chunk_slices(selection, dimension, True): - original_blocks = chunk.blocks[slices] - palette, blocks = numpy.unique(original_blocks, return_inverse=True) - blocks = blocks.reshape(original_blocks.shape) - lut = numpy.array( - [ - world.palette.get_add_block( - world.palette[block_id] + waterlog_block # get the Block object for that id and add the user specified block - ) # register the new block / get the numerical id if it was already registered - for block_id in palette - ] # add the new id to the palette - ) - - chunk.blocks[slices] = lut[blocks] - - chunk.changed = True - count += 1 - yield 100 * count / iter_count - else: - raise Exception('Please specify a block before running the waterlog operation') - - -def show_ui(parent, world: "World", options: dict) -> dict: - dialog = SimpleDialog(parent, 'Waterlog') - block_define = BlockDefine( - dialog, - world.world_wrapper.translation_manager, - *(options.get("fill_block_options", []) or [world.world_wrapper.platform]) - ) - dialog.sizer.Add(block_define) - dialog.Fit() - if dialog.ShowModal() == wx.ID_OK: - options = { - "fill_block": world.world_wrapper.translation_manager.get_version( - block_define.platform, - block_define.version - ).block.to_universal( - block_define.block, - force_blockstate=block_define.force_blockstate - )[0], - "fill_block_options": block_define.options - } - return options - - -export = { - "v": 1, # a version 1 plugin - "name": "Waterlog", # the name of the plugin - "features": ["src_selection", "wxoptions"], - "inputs": ["src_selection", "options"], # the inputs to give to the plugin - "operation": waterlog, # the actual function to call when running the plugin - "wxoptions": show_ui -} diff --git a/amulet_map_editor/programs/__init__.py b/amulet_map_editor/programs/__init__.py index 52916764..12dad1d3 100644 --- a/amulet_map_editor/programs/__init__.py +++ b/amulet_map_editor/programs/__init__.py @@ -9,8 +9,8 @@ from amulet import world_interface from amulet_map_editor import log -from amulet_map_editor.amulet_wx.simple import SimplePanel -from amulet_map_editor.amulet_wx.world_select import WorldUI +from amulet_map_editor.amulet_wx.ui.simple import SimplePanel +from amulet_map_editor.amulet_wx.ui.select_world import WorldUI if TYPE_CHECKING: from amulet.api.world import World @@ -62,10 +62,10 @@ def menu(self, menu: MenuData) -> MenuData: class WorldManagerUI(wx.Notebook, BaseWorldUI): - def __init__(self, parent, path): + def __init__(self, parent: wx.Window, path: str, close_self_callback: Callable[[], None]): super().__init__(parent, style=wx.NB_LEFT) - self._finished = False - self.Bind(wx.EVT_NOTEBOOK_PAGE_CHANGED, self._page_change) + self._path = path + self._close_self_callback = close_self_callback try: self.world = world_interface.load_world(path) except LoaderNoneMatched as e: @@ -75,10 +75,14 @@ def __init__(self, parent, path): self._extensions: List[BaseWorldProgram] = [] self._last_extension: int = -1 self._load_extensions() - self._finished = True + self.Bind(wx.EVT_NOTEBOOK_PAGE_CHANGED, self._page_change) + + @property + def path(self) -> str: + return self._path def menu(self, menu: MenuData) -> MenuData: - menu.setdefault('&File', {}).setdefault('exit', {}).setdefault('Close World', lambda evt: self.GetGrandParent().close_world(self.world.world_path)) + menu.setdefault('&File', {}).setdefault('exit', {}).setdefault('Close World', lambda evt: self._close_self_callback) return self._extensions[self.GetSelection()].menu(menu) def _load_extensions(self): @@ -87,7 +91,7 @@ def _load_extensions(self): select = True for extension_name, extension in _extensions: try: - ext = extension(self, self.world) + ext = extension(self, self.world, self._close_self_callback) self._extensions.append(ext) self.AddPage(ext, extension_name, select) select = False @@ -109,10 +113,9 @@ def close(self): def _page_change(self, evt): """Method to fire when the page is changed""" if self.GetSelection() != self._last_extension: - if self._finished: - self._extensions[self._last_extension].disable() - self._extensions[self.GetSelection()].enable() - self.GetGrandParent().create_menu() + self._extensions[self._last_extension].disable() + self._extensions[self.GetSelection()].enable() + self.GetGrandParent().create_menu() self._last_extension = self.GetSelection() def disable(self): @@ -133,11 +136,16 @@ def disable(self): """Run when the panel is hidden/disabled""" pass - def is_closeable(self): + def is_closeable(self) -> bool: + """ + Check if it is safe to close the UI. + If this is going to return False it should notify the user. + :return: True if the program can be closed, False otherwise + """ return True def close(self): - """Run when the world is closed""" + """Fully close the UI. Called when destroying the UI.""" pass def menu(self, menu: MenuData) -> MenuData: @@ -145,12 +153,13 @@ def menu(self, menu: MenuData) -> MenuData: class AboutExtension(SimplePanel, BaseWorldProgram): - def __init__(self, container, world: 'World'): + def __init__(self, container, world: 'World', close_self_callback: Callable[[], None]): SimplePanel.__init__( self, container ) self.world = world + self._close_self_callback = close_self_callback self._close_world_button = wx.Button(self, wx.ID_ANY, label='Close World') self._close_world_button.Bind(wx.EVT_BUTTON, self._close_world) @@ -175,7 +184,7 @@ def __init__(self, container, world: 'World'): ) def _close_world(self, evt): - self.GetGrandParent().GetParent().close_world(self.world.world_path) + self._close_self_callback() _fixed_extensions.append(("About", AboutExtension)) diff --git a/amulet_map_editor/programs/convert/convert.py b/amulet_map_editor/programs/convert/convert.py index e8746d60..334a58b4 100644 --- a/amulet_map_editor/programs/convert/convert.py +++ b/amulet_map_editor/programs/convert/convert.py @@ -1,14 +1,14 @@ import wx from concurrent.futures import ThreadPoolExecutor import webbrowser -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable from amulet import world_interface from amulet.api.world import World from amulet_map_editor import lang, log -from amulet_map_editor.amulet_wx.simple import SimplePanel -from amulet_map_editor.amulet_wx.world_select import WorldSelectDialog, WorldUI +from amulet_map_editor.amulet_wx.ui.simple import SimplePanel +from amulet_map_editor.amulet_wx.ui.select_world import WorldSelectDialog, WorldUI from amulet_map_editor.programs import BaseWorldProgram, MenuData if TYPE_CHECKING: @@ -20,12 +20,13 @@ class ConvertExtension(SimplePanel, BaseWorldProgram): - def __init__(self, container, world: World): + def __init__(self, container, world: World, close_self_callback: Callable[[], None]): SimplePanel.__init__( self, container ) self.world = world + self._close_self_callback = close_self_callback self._close_world_button = wx.Button(self, wx.ID_ANY, label='Close World') self._close_world_button.Bind(wx.EVT_BUTTON, self._close_world) @@ -94,6 +95,7 @@ def _help_controls(self): def _show_world_select(self, evt): select_world = WorldSelectDialog(self, self._output_world_callback) select_world.ShowModal() + select_world.Destroy() def _output_world_callback(self, path): if path == self.world.world_path: @@ -160,4 +162,4 @@ def is_closeable(self): return work_count == 0 def _close_world(self, evt): - self.GetGrandParent().GetParent().close_world(self.world.world_path) + self._close_self_callback() diff --git a/amulet_map_editor/programs/edit/__init__.py b/amulet_map_editor/programs/edit/__init__.py index 8da4d8f8..c05f7f6f 100644 --- a/amulet_map_editor/programs/edit/__init__.py +++ b/amulet_map_editor/programs/edit/__init__.py @@ -1,5 +1,7 @@ from .edit import EditExtension +from .canvas.ui.select_location import SelectLocationUI + export = { "name": "3D Editor", "ui": EditExtension diff --git a/amulet_map_editor/programs/edit/canvas/__init__.py b/amulet_map_editor/programs/edit/canvas/__init__.py index e69de29b..90eaf733 100644 --- a/amulet_map_editor/programs/edit/canvas/__init__.py +++ b/amulet_map_editor/programs/edit/canvas/__init__.py @@ -0,0 +1,2 @@ +from .base_edit_canvas import BaseEditCanvas +from .edit_canvas import EditCanvas \ No newline at end of file diff --git a/amulet_map_editor/programs/edit/canvas/canvas.py b/amulet_map_editor/programs/edit/canvas/base_edit_canvas.py similarity index 67% rename from amulet_map_editor/programs/edit/canvas/canvas.py rename to amulet_map_editor/programs/edit/canvas/base_edit_canvas.py index 71d222e9..e314d752 100644 --- a/amulet_map_editor/programs/edit/canvas/canvas.py +++ b/amulet_map_editor/programs/edit/canvas/base_edit_canvas.py @@ -1,41 +1,52 @@ import wx from OpenGL.GL import * import os -from typing import TYPE_CHECKING, Optional, Any, Dict, Tuple, List, Generator, Union +from typing import TYPE_CHECKING, Optional, Any, Dict, Tuple, List, Generator, Set import numpy +import weakref import minecraft_model_reader from amulet.api.chunk import Chunk from amulet.api.structure import Structure from amulet.api.errors import ChunkLoadError -from amulet.api.data_types import PointCoordinatesNDArray +from amulet.api.data_types import PointCoordinatesNDArray, Dimension, BlockCoordinates from amulet.api.selection import SelectionGroup +from amulet_map_editor.opengl.data_types import CameraLocationType, CameraRotationType from amulet_map_editor.opengl.mesh.world_renderer.world import RenderWorld, cos, tan, atan from amulet_map_editor.opengl.mesh.selection import RenderSelection, RenderSelectionGroup from amulet_map_editor.opengl.mesh.structure import RenderStructure from amulet_map_editor.opengl import textureatlas from amulet_map_editor.opengl.canvas.base import BaseCanvas from amulet_map_editor import log -from ..events import CameraMoveEvent +from amulet_map_editor.programs.edit.canvas.events import ( + CameraMoveEvent, + CameraRotateEvent, + DimensionChangeEvent, + SelectionPointChangeEvent, +) if TYPE_CHECKING: from amulet.api.world import World -MODE_NORMAL = 0 # normal selection -MODE_DISABLED = 1 # non-interactive selection boxes -MODE_STRUCTURE = 2 # MODE_DISABLED and draw structure if exists +class BaseEditCanvas(BaseCanvas): + """Adds base logic for drawing everything related to the edit program to the BaseCanvas. + All the user interaction code is implemented in ControllableEditCanvas to make them easier to read.""" + + background_colour = (0.5, 0.66, 1.0) -class EditCanvas(BaseCanvas): def __init__(self, parent: wx.Window, world: 'World'): super().__init__(parent) - self._last_mouse_x = 0 - self._last_mouse_y = 0 + glClearColor(*self.background_colour, 1.0) + self.Hide() + + self._bound_events: Set[wx.PyEventBinder] = set() + + self._world = weakref.ref(world) self._mouse_delta_x = 0 self._mouse_delta_y = 0 self._mouse_lock = False - self._mouse_moved = False # load the resource packs os.makedirs('resource_packs', exist_ok=True) @@ -64,39 +75,71 @@ def __init__(self, parent: wx.Window, world: 'World'): self._resource_pack_translator ) - self._camera: List[float] = [0.0, 100.0, 0.0, 45.0, 45.0] + self._camera_location: CameraLocationType = (0.0, 100.0, 0.0) + self._camera_rotation: CameraRotationType = (45.0, 45.0) self._camera_move_speed = 2 self._camera_rotate_speed = 2 self._select_distance = 10 self._select_distance2 = 10 - self._select_mode = MODE_NORMAL + self._selection_moved = True # has the selection point moved and does the box need rebuilding + self._selection_location: BlockCoordinates = (0, 0, 0) + + self._draw_selection = True + self._selection_editable = True self._selection_group = RenderSelectionGroup( self.context_identifier, self._texture_bounds, self._gl_texture_atlas ) + + self._draw_structure = False self._structure: Optional[RenderStructure] = None - self._structure_locations: List[numpy.ndarray] = [] + self._structure_locations: List[numpy.ndarray] = [] # TODO rewrite this self._draw_timer = wx.Timer(self) - self.Bind(wx.EVT_TIMER, self._on_draw, self._draw_timer) - self._gc_timer = wx.Timer(self) - self.Bind(wx.EVT_TIMER, self._gc, self._gc_timer) - self._rebuild_timer = wx.Timer(self) + self._bind_base_events() + + def reset_bound_events(self): + """Unbind all events and re-bind the default events. + We are allowing users to bind custom events so we should have a way to reset what is bound.""" + for event in self._bound_events: + self.Unbind(event) + self._bind_base_events() + + def _bind_base_events(self): + self.Bind(wx.EVT_TIMER, self._on_draw, self._draw_timer) + self.Bind(wx.EVT_TIMER, self._gc, self._gc_timer) self.Bind(wx.EVT_TIMER, self._rebuild, self._rebuild_timer) + def Bind(self, event, handler, source=None, id=wx.ID_ANY, id2=wx.ID_ANY): + """Bind an event to the canvas.""" + self._bound_events.add(event) + super().Bind(event, handler, source, id, id2) + + @property + def world(self) -> "World": + return self._world() + + @property + def selection_location(self) -> BlockCoordinates: + return self._selection_location + @property def selection_group(self) -> SelectionGroup: + """Create a SelectionGroup class from the selected boxes.""" return self._selection_group.create_selection_group() @property def active_selection(self) -> Optional[RenderSelection]: + """Get the selection box that is currently active. + May be None if no selection box exists.""" return self._selection_group.active_selection def enable(self): + """Enable the canvas and start it working.""" self.SetCurrent(self._context) self._render_world.enable() self._draw_timer.Start(33) @@ -104,22 +147,28 @@ def enable(self): self._rebuild_timer.Start(1000) def disable(self): + """Disable the canvas and unload all geometry.""" self._draw_timer.Stop() self._gc_timer.Stop() self._rebuild_timer.Stop() self._render_world.disable() - def disable_threads(self): + def _disable_threads(self): + """Stop the generation of new chunk geometry. + Makes it safe to modify the world data.""" self._render_world.chunk_generator.stop() - def enable_threads(self): + def _enable_threads(self): + """Start the generation of new chunk geometry.""" self._render_world.chunk_generator.start() def close(self): + """Close and destroy the canvas and all contained data.""" self._render_world.close() super()._close() def is_closeable(self): + """Check that the canvas and contained data is safe to be closed.""" return self._render_world.is_closeable() def _load_resource_pack(self, *resource_packs: minecraft_model_reader.JavaRP): @@ -127,6 +176,7 @@ def _load_resource_pack(self, *resource_packs: minecraft_model_reader.JavaRP): self._create_atlas() def _create_atlas(self): + """Create and bind the atlas texture.""" texture_atlas, self._texture_bounds, width, height = textureatlas.create_atlas( self._resource_pack.textures ) @@ -136,7 +186,7 @@ def _create_atlas(self): log.info('Finished setting up texture atlas in OpenGL') @property - def structure(self) -> RenderStructure: + def structure(self) -> Optional[RenderStructure]: return self._structure @structure.setter @@ -150,10 +200,39 @@ def structure(self, structure: Structure): self._resource_pack_translator ) + @property + def draw_structure(self) -> bool: + """Should the moveable structure be drawn""" + return self._draw_structure + + @draw_structure.setter + def draw_structure(self, draw_structure: bool): + self._draw_structure = bool(draw_structure) + @property def structure_locations(self) -> List[numpy.ndarray]: + """The locations where the structure should be displayed. DO NOT USE THIS YET. + todo: rewrite this to allow rotation.""" return self._structure_locations + @property + def draw_selection(self) -> bool: + """Should the selection box(es) be drawn""" + return self._draw_selection + + @draw_selection.setter + def draw_selection(self, draw_selection: bool): + self._draw_selection = bool(draw_selection) + + @property + def selection_editable(self) -> bool: + """Should the selection box(es) be editable""" + return self._selection_editable + + @selection_editable.setter + def selection_editable(self, selection_editable: bool): + self._selection_editable = bool(selection_editable) + @property def select_distance(self) -> int: return self._select_distance @@ -161,7 +240,7 @@ def select_distance(self) -> int: @select_distance.setter def select_distance(self, distance: int): self._select_distance = distance - self._change_box_location() + self._selection_moved = True @property def select_distance2(self) -> int: @@ -170,34 +249,40 @@ def select_distance2(self) -> int: @select_distance2.setter def select_distance2(self, distance: int): self._select_distance2 = distance - self._change_box_location() + self._selection_moved = True @property - def select_mode(self) -> int: - return self._select_mode - - @select_mode.setter - def select_mode(self, select_mode: int): - self._select_mode = select_mode - - @property - def dimension(self) -> str: + def dimension(self) -> Dimension: return self._render_world.dimension @dimension.setter - def dimension(self, dimension: int): + def dimension(self, dimension: Dimension): self._render_world.dimension = dimension + wx.PostEvent(self, DimensionChangeEvent(dimension=dimension)) @property - def camera_location(self) -> Tuple[float, float, float]: - return tuple(self._camera[:3]) + def camera_location(self) -> CameraLocationType: + return self._camera_location @camera_location.setter - def camera_location(self, location: Tuple[Union[int, float], Union[int, float], Union[int, float]]): - self._camera[:3] = location + def camera_location(self, location: CameraLocationType): + assert len(location) == 3 and all(isinstance(v, (int, float)) for v in location), "format for camera_location is invalid" + self._camera_location = location self._transformation_matrix = None - self._change_box_location() - wx.PostEvent(self, CameraMoveEvent(x=self._camera[0], y=self._camera[1], z=self._camera[2], rx=self._camera[3], ry=self._camera[4])) + self._selection_moved = True + wx.PostEvent(self, CameraMoveEvent(location=self.camera_location)) + + @property + def camera_rotation(self) -> CameraRotationType: + return self._camera_rotation + + @camera_rotation.setter + def camera_rotation(self, rotation: CameraRotationType): + assert len(rotation) == 2 and all(isinstance(v, (int, float)) for v in rotation), "format for camera_rotation is invalid" + self._camera_rotation = rotation + self._transformation_matrix = None + self._selection_moved = True + wx.PostEvent(self, CameraRotateEvent(rotation=self.camera_rotation)) @property def camera_move_speed(self) -> float: @@ -224,19 +309,10 @@ def _change_box_location(self): position, box_index = self._box_location_distance(self.select_distance) else: position, box_index = self._box_location_closest() - + self._selection_location = position.tolist() + wx.PostEvent(self, SelectionPointChangeEvent(location=position.tolist())) self._selection_group.update_position(position, box_index) - # if self._selection_box.select_state == 0: - # (x, y, z) = self._selection_box.point1 = self._selection_box.point2 = location - # wx.PostEvent(self, BoxGreenCornerChangeEvent(x=x, y=y, z=z)) - # wx.PostEvent(self, BoxBlueCornerChangeEvent(x=x, y=y, z=z)) - # elif self._selection_box.select_state == 1: - # (x, y, z) = self._selection_box.point2 = location - # wx.PostEvent(self, BoxBlueCornerChangeEvent(x=x, y=y, z=z)) - # elif self._selection_box.select_state == 2: - # self._selection_box2.point1 = self._selection_box2.point2 = location - def ray_collision(self): vector_start = self.camera_location direction_vector = self._look_vector() @@ -256,6 +332,7 @@ def _box_location_closest(self) -> Tuple[PointCoordinatesNDArray, Optional[int]] cx: Optional[int] = None cz: Optional[int] = None chunk: Optional[Chunk] = None + in_air = False box_index, nearest_selection_box = self._selection_group.closest_intersection(self.camera_location, self._look_vector()) @@ -276,7 +353,11 @@ def _box_location_closest(self) -> Tuple[PointCoordinatesNDArray, Optional[int]] chunk = None if chunk is not None and self._render_world.world.palette[chunk.blocks[x % 16, y, z % 16]].namespaced_name != 'universal_minecraft:air': - return location, None + # the block is not air + if in_air: # if we have previously found an air block + return location, None + elif not in_air: + in_air = True return location, None def _box_location_distance(self, distance: int) -> Tuple[PointCoordinatesNDArray, Optional[int]]: @@ -301,7 +382,7 @@ def _look_vector(self) -> numpy.ndarray: screen_dx = atan(self.aspect_ratio * tan(self.fov / 2) * self._mouse_delta_x / screen_x) screen_dy = atan(cos(screen_dx) * tan(self.fov / 2) * self._mouse_delta_y / screen_y) look_vector = numpy.matmul(self.rotation_matrix(screen_dy, screen_dx), look_vector) - look_vector = numpy.matmul(self.rotation_matrix(*self._camera[3:5]), look_vector)[:3] + look_vector = numpy.matmul(self.rotation_matrix(*self.camera_rotation), look_vector)[:3] look_vector[abs(look_vector) < 0.000001] = 0.000001 return look_vector @@ -365,12 +446,16 @@ def _on_draw(self, event): def draw(self): glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) self._render_world.draw(self.transformation_matrix) - if self._select_mode == MODE_STRUCTURE and self._structure is not None: + if self._draw_structure and self._structure is not None: transform = numpy.eye(4, dtype=numpy.float32) for location in self.structure_locations: transform[3, 0:3] = location self._structure.draw(numpy.matmul(transform, self.transformation_matrix), 0, 0) - self._selection_group.draw(self.transformation_matrix, tuple(self.camera_location), self._select_mode == MODE_NORMAL) + if self._selection_moved: + self._selection_moved = False + self._change_box_location() + if self._draw_selection: + self._selection_group.draw(self.transformation_matrix, tuple(self.camera_location), self._selection_editable) self.SwapBuffers() def _gc(self, event): diff --git a/amulet_map_editor/programs/edit/canvas/controllable_canvas.py b/amulet_map_editor/programs/edit/canvas/controllable_edit_canvas.py similarity index 73% rename from amulet_map_editor/programs/edit/canvas/controllable_canvas.py rename to amulet_map_editor/programs/edit/canvas/controllable_edit_canvas.py index 0b625105..a9beace2 100644 --- a/amulet_map_editor/programs/edit/canvas/controllable_canvas.py +++ b/amulet_map_editor/programs/edit/canvas/controllable_edit_canvas.py @@ -1,46 +1,40 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Set import wx import numpy -from .canvas import EditCanvas +from .base_edit_canvas import BaseEditCanvas from amulet_map_editor.opengl.mesh.world_renderer.world import sin, cos -from ..events import ( - CameraMoveEvent, - BoxGreenCornerChangeEvent, - BoxBlueCornerChangeEvent, - BoxCoordsEnableEvent, -) -from amulet_map_editor.amulet_wx.key_config import serialise_key_event, KeybindGroup +from amulet_map_editor.amulet_wx.util.key_config import serialise_key_event, KeybindGroup, ActionLookupType if TYPE_CHECKING: from amulet.api.world import World - from amulet_map_editor.programs.edit.edit import EditExtension -key_map = { - 'up': wx.WXK_SPACE, - 'down': wx.WXK_SHIFT, - 'forwards': 87, - 'backwards': 83, - 'left': 65, - 'right': 68, - - 'look_left': 74, - 'look_right': 76, - 'look_up': 73, - 'look_down': 75, -} +class ControllableEditCanvas(BaseEditCanvas): + """Adds the user interaction logic to BaseEditCanvas""" + def __init__(self, world_panel: wx.Window, world: 'World'): + super().__init__(world_panel, world) + self._last_mouse_x = 0 + self._last_mouse_y = 0 + self._mouse_moved = False # has the mouse position changed since the last frame + self._persistent_actions: Set[str] = set() # wx only fires events for when a key is initially pressed or released. This stores actions for keys that are held down. + self._key_binds: ActionLookupType = {} # a store for which keys run which actions + # timer to deal with persistent actions + self._input_timer = wx.Timer(self) + self._bind_controllable_events() -class ControllableEditCanvas(EditCanvas): - def __init__(self, world_panel: 'EditExtension', world: 'World'): - super().__init__(world_panel, world) - self._persistent_actions = set() - self._key_binds = {} + def reset_bound_events(self): + """Unbind all events and re-bind the default events. + We are allowing users to bind custom events so we should have a way to reset what is bound.""" + super().reset_bound_events() + self._bind_controllable_events() + def _bind_controllable_events(self): self.Bind(wx.EVT_KILL_FOCUS, self._on_loss_focus) self.Bind(wx.EVT_MOTION, self._on_mouse_motion) + # key press actions self.Bind(wx.EVT_LEFT_DOWN, self._press) self.Bind(wx.EVT_LEFT_UP, self._release) self.Bind(wx.EVT_MIDDLE_DOWN, self._press) @@ -51,7 +45,6 @@ def __init__(self, world_panel: 'EditExtension', world: 'World'): self.Bind(wx.EVT_KEY_UP, self._release) self.Bind(wx.EVT_MOUSEWHEEL, self._release) - self._input_timer = wx.Timer(self) self.Bind(wx.EVT_TIMER, self._process_persistent_inputs, self._input_timer) def enable(self): @@ -66,12 +59,15 @@ def set_key_binds(self, key_binds: KeybindGroup): self._key_binds = dict(zip(key_binds.values(), key_binds.keys())) def _press(self, evt): + """Event to handle a number of different key presses""" self._process_action(evt, True) def _release(self, evt): + """Event to handle a number of different key releases""" self._process_action(evt, False) def _process_action(self, evt, press: bool): + """Logic to handle a key being pressed or released.""" key = serialise_key_event(evt) if key is None: return @@ -106,7 +102,8 @@ def _process_action(self, evt, press: bool): self._selection_group.delete_active() else: # run once on button press and frequently until released if action == "box click": - self._box_click() + if self.selection_editable: + self.box_select("add box modifier" in self._persistent_actions) elif action == "toggle mouse mode": self._toggle_mouse_lock() elif action == "speed+": @@ -122,17 +119,23 @@ def _process_action(self, evt, press: bool): if a in self._persistent_actions: self._persistent_actions.remove(a) - def _toggle_mouse_lock(self): - self.SetFocus() - if self._mouse_lock: - self._release_mouse() - else: - self.SetCursor(wx.Cursor(wx.CURSOR_BLANK)) - self._mouse_delta_x = self._mouse_delta_y = self._last_mouse_x = self._last_mouse_y = 0 - self._mouse_lock = True - self._change_box_location() + def box_select(self, add_modifier: bool = False): + position = self._selection_group.box_click(add_modifier) + if position is not None: + self.select_distance2 = int(numpy.linalg.norm(position - self.camera_location)) + + # if self._selection_box.select_state <= 1: + # self._selection_box.select_state += 1 + # elif self._selection_box.select_state == 2: + # self._selection_box.point1, self._selection_box.point2 = self._selection_box2.point1, self._selection_box2.point2 + # self._selection_box.select_state = 1 + # x, y, z = self._selection_box.point1 + # wx.PostEvent(self, BoxGreenCornerChangeEvent(x=x, y=y, z=z)) + # wx.PostEvent(self, BoxBlueCornerChangeEvent(x=x, y=y, z=z)) + # wx.PostEvent(self, BoxCoordsEnableEvent(enabled=self._selection_box.select_state == 2)) def _process_persistent_inputs(self, evt): + """Handle actions run by keys that are being held down.""" forward, up, right, pitch, yaw = 0, 0, 0, 0, 0 if 'up' in self._persistent_actions: up += 1 @@ -159,48 +162,43 @@ def _process_persistent_inputs(self, evt): evt.Skip() def move_camera_relative(self, forward, up, right, pitch, yaw): - if (forward, up, right, pitch, yaw) == (0, 0, 0, 0, 0): + """Move the camera relative to its current location.""" + if not any((forward, up, right, pitch, yaw)): if not self._mouse_lock and self._mouse_moved: self._mouse_moved = False - self._change_box_location() + self._selection_moved = True return - self._camera[0] += self._camera_move_speed * (cos(self._camera[4]) * right + sin(self._camera[4]) * forward) - self._camera[1] += self._camera_move_speed * up - self._camera[2] += self._camera_move_speed * (sin(self._camera[4]) * right - cos(self._camera[4]) * forward) - - self._camera[3] += self._camera_rotate_speed * pitch - if not -90 <= self._camera[3] <= 90: - self._camera[3] = max(min(self._camera[3], 90), -90) - self._camera[4] += self._camera_rotate_speed * yaw - self._transformation_matrix = None - self._render_world.camera = self._camera - self._change_box_location() - wx.PostEvent(self, CameraMoveEvent(x=self._camera[0], y=self._camera[1], z=self._camera[2], rx=self._camera[3], ry=self._camera[4])) - - def box_select(self, add_modifier: bool = False): - position = self._selection_group.box_click(add_modifier) - if position is not None: - self.select_distance2 = int(numpy.linalg.norm(position - self.camera_location)) + x, y, z = self.camera_location + rx, ry = self.camera_rotation + x += self._camera_move_speed * (cos(ry) * right + sin(ry) * forward) + y += self._camera_move_speed * up + z += self._camera_move_speed * (sin(ry) * right - cos(ry) * forward) + + rx += self._camera_rotate_speed * pitch + if not -90 <= rx <= 90: + rx = max(min(rx, 90), -90) + ry += self._camera_rotate_speed * yaw + self.camera_location = self._render_world.camera_location = (x, y, z) + self.camera_rotation = self._render_world.camera_rotation = (rx, ry) - # if self._selection_box.select_state <= 1: - # self._selection_box.select_state += 1 - # elif self._selection_box.select_state == 2: - # self._selection_box.point1, self._selection_box.point2 = self._selection_box2.point1, self._selection_box2.point2 - # self._selection_box.select_state = 1 - # x, y, z = self._selection_box.point1 - # wx.PostEvent(self, BoxGreenCornerChangeEvent(x=x, y=y, z=z)) - # wx.PostEvent(self, BoxBlueCornerChangeEvent(x=x, y=y, z=z)) - # wx.PostEvent(self, BoxCoordsEnableEvent(enabled=self._selection_box.select_state == 2)) - - def _box_click(self): - if self.select_mode == 0: - self.box_select("add box modifier" in self._persistent_actions) + def _toggle_mouse_lock(self): + """Toggle mouse selection mode.""" + self.SetFocus() + if self._mouse_lock: + self._release_mouse() + else: + self.SetCursor(wx.Cursor(wx.CURSOR_BLANK)) + self._mouse_delta_x = self._mouse_delta_y = self._last_mouse_x = self._last_mouse_y = 0 + self._mouse_lock = True + self._selection_moved = True def _release_mouse(self): + """Release the mouse""" self.SetCursor(wx.NullCursor) self._mouse_lock = False def _on_mouse_motion(self, evt): + """Event fired when the mouse is moved.""" self.SetFocus() if self._mouse_lock: if self._last_mouse_x == 0: @@ -235,9 +233,11 @@ def _on_mouse_motion(self, evt): self._mouse_moved = True def _on_loss_focus(self, evt): + """Event fired when the user tabs out of the window.""" self._escape() evt.Skip() def _escape(self): + """Release the mouse and remove all key presses to the camera doesn't fly off into the distance.""" self._persistent_actions.clear() self._release_mouse() diff --git a/amulet_map_editor/programs/edit/canvas/edit_canvas.py b/amulet_map_editor/programs/edit/canvas/edit_canvas.py new file mode 100644 index 00000000..999869af --- /dev/null +++ b/amulet_map_editor/programs/edit/canvas/edit_canvas.py @@ -0,0 +1,214 @@ +import wx +from typing import TYPE_CHECKING, Callable, Any +from types import GeneratorType +import time +import traceback + +from amulet.api.data_types import OperationReturnType +from amulet.api.structure import Structure, structure_cache + +from amulet_map_editor import CONFIG, log +from amulet_map_editor.programs.edit.edit import EDIT_CONFIG_ID +from amulet_map_editor.programs.edit.key_config import DefaultKeys, DefaultKeybindGroupId, PresetKeybinds +from amulet_map_editor.programs.edit.canvas.ui.goto import show_goto +from amulet_map_editor.programs.edit.canvas.ui.tool import Tool +from amulet_map_editor.programs.edit.plugins import OperationError, OperationSuccessful, OperationSilentAbort +from amulet_map_editor.programs.edit.plugins.stock_plugins.internal_operations.cut import cut +from amulet_map_editor.programs.edit.plugins.stock_plugins.internal_operations.copy import copy +from amulet_map_editor.programs.edit.plugins.stock_plugins.internal_operations.delete import delete + +from amulet_map_editor.programs.edit.canvas.events import ( + UndoEvent, + RedoEvent, + CreateUndoEvent, + SaveEvent, + EditCloseEvent, + PasteEvent, + ToolChangeEvent, +) +from amulet_map_editor.programs.edit.canvas.controllable_edit_canvas import ControllableEditCanvas +from amulet_map_editor.programs.edit.canvas.ui.file import FilePanel + +if TYPE_CHECKING: + from amulet.api.world import World + + +def show_loading_dialog( + run: Callable[[], OperationReturnType], title: str, message: str, parent: wx.Window +) -> Any: + dialog = wx.ProgressDialog( + title, + message, + parent=parent, + style=wx.PD_APP_MODAL + | wx.PD_ELAPSED_TIME + | wx.PD_REMAINING_TIME + | wx.PD_AUTO_HIDE, + ) + t = time.time() + try: + obj = run() + if isinstance(obj, GeneratorType): + try: + while True: + progress = next(obj) + if isinstance(progress, (list, tuple)): + if len(progress) >= 2: + message = progress[1] + if len(progress) >= 1: + progress = progress[0] + if isinstance(progress, (int, float)) and isinstance(message, str): + dialog.Update(min(99.99, max(0, progress)), message) + except StopIteration as e: + obj = e.value + except Exception as e: + dialog.Destroy() + raise e + time.sleep(max(0.2 - time.time() + t, 0)) + dialog.Destroy() + return obj + + +class EditCanvas(ControllableEditCanvas): + """Adds embedded UI elements to the canvas.""" + def __init__(self, parent: wx.Window, world: "World"): + super().__init__(parent, world) + config = CONFIG.get(EDIT_CONFIG_ID, {}) + user_keybinds = config.get("user_keybinds", {}) + group = config.get("keybind_group", DefaultKeybindGroupId) + if group in user_keybinds: + keybinds = user_keybinds[group] + elif group in PresetKeybinds: + keybinds = PresetKeybinds[group] + else: + keybinds = DefaultKeys + self.set_key_binds( + keybinds + ) + + canvas_sizer = wx.BoxSizer(wx.VERTICAL) + self.SetSizer(canvas_sizer) + + file_sizer = wx.BoxSizer(wx.HORIZONTAL) + file_sizer.AddStretchSpacer(1) + self._file_panel = FilePanel(self) + file_sizer.Add(self._file_panel, 0, wx.EXPAND, 0) + canvas_sizer.Add(file_sizer, 0, wx.EXPAND, 0) + + self._tool_sizer = Tool(self) + canvas_sizer.Add(self._tool_sizer, 1, wx.EXPAND, 0) + + self.bind_events() + + def bind_events(self): + self._file_panel.bind_events() + self._tool_sizer.bind_events() + + def run_operation( + self, + operation: Callable[[], OperationReturnType], + title="", + msg="", + throw_exceptions=False + ) -> Any: + self._disable_threads() + err = None + out = None + try: + out = show_loading_dialog( + operation, + title, + msg, + self, + ) + self.world.create_undo_point() + wx.PostEvent(self, CreateUndoEvent()) + except OperationError as e: + msg = f"Error running operation: {e}" + log.info(msg) + self.world.restore_last_undo_point() + wx.MessageDialog(self, msg, style=wx.OK).ShowModal() + err = e + except OperationSuccessful as e: + msg = str(e) + log.info(msg) + self.world.restore_last_undo_point() + wx.MessageDialog(self, msg, style=wx.OK).ShowModal() + err = e + except OperationSilentAbort as e: + self.world.restore_last_undo_point() + err = e + except Exception as e: + self.world.restore_last_undo_point() + log.error(traceback.format_exc()) + wx.MessageDialog(self, f"Exception running operation: {e}\nSee the console for more details", style=wx.OK).ShowModal() + err = e + + self._enable_threads() + if err is not None and throw_exceptions: + raise err + return out + + def undo(self): + self.world.undo() + wx.PostEvent(self, UndoEvent()) + + def redo(self): + self.world.redo() + wx.PostEvent(self, RedoEvent()) + + def cut(self): + self.run_operation( + lambda: cut( + self.world, + self.dimension, + self.selection_group + ) + ) + + def copy(self): + self.run_operation( + lambda: copy( + self.world, + self.dimension, + self.selection_group + ) + ) + + def paste(self, structure: Structure = None): + if not isinstance(structure, Structure): + if structure_cache: + structure = structure_cache.get_structure() + else: + wx.MessageBox("A structure needs to be copied before one can be pasted.") + return + wx.PostEvent(self, ToolChangeEvent(tool="Select")) + wx.PostEvent(self, PasteEvent(structure=structure)) + + def delete(self): + self.run_operation( + lambda: delete( + self.world, + self.dimension, + self.selection_group + ) + ) + + def goto(self): + location = show_goto(self, *self.camera_location) + if location: + self.camera_location = location + + def save(self): + self._disable_threads() + + def save(): + for chunk_index, chunk_count in self.world.save_iter(): + yield chunk_index / chunk_count + + show_loading_dialog(lambda: save(), f"Saving world.", "Please wait.", self) + wx.PostEvent(self, SaveEvent()) + self._enable_threads() + + def close(self): + wx.PostEvent(self, EditCloseEvent()) diff --git a/amulet_map_editor/programs/edit/canvas/events.py b/amulet_map_editor/programs/edit/canvas/events.py new file mode 100644 index 00000000..d35fa5cd --- /dev/null +++ b/amulet_map_editor/programs/edit/canvas/events.py @@ -0,0 +1,24 @@ +from wx.lib import newevent + +CameraMoveEvent, EVT_CAMERA_MOVE = newevent.NewEvent() +CameraRotateEvent, EVT_CAMERA_ROTATE = newevent.NewEvent() +DimensionChangeEvent, EVT_DIMENSION_CHANGE = newevent.NewEvent() + +# the active tool changed +ToolChangeEvent, EVT_TOOL_CHANGE = newevent.NewEvent() +PasteEvent, EVT_PASTE = newevent.NewEvent() + +UndoEvent, EVT_UNDO = newevent.NewEvent() +RedoEvent, EVT_REDO = newevent.NewEvent() +CreateUndoEvent, EVT_CREATE_UNDO = newevent.NewEvent() +SaveEvent, EVT_SAVE = newevent.NewEvent() +EditCloseEvent, EVT_EDIT_CLOSE = newevent.NewEvent() + +# the block highlighted by the cursor changed position. +SelectionPointChangeEvent, EVT_SELECTION_POINT_CHANGE = newevent.NewEvent() + +# events fired when the active selection box changes. TODO: reimplement these +BoxChangeEvent, EVT_BOX_CHANGE = newevent.NewEvent() # one or more of the box coordinates have changed +BoxPoint1ChangeEvent, EVT_BOX_POINT_1_CHANGE = newevent.NewEvent() # the first box corner has changed +BoxPoint2ChangeEvent, EVT_BOX_POINT_2_CHANGE = newevent.NewEvent() # the second box corner has changed +BoxEditToggleEvent, EVT_BOX_EDIT_TOGGLE = newevent.NewEvent() # the box has switched between edit and static mode diff --git a/amulet_map_editor/programs/edit/canvas/ui/__init__.py b/amulet_map_editor/programs/edit/canvas/ui/__init__.py new file mode 100644 index 00000000..358c9b42 --- /dev/null +++ b/amulet_map_editor/programs/edit/canvas/ui/__init__.py @@ -0,0 +1 @@ +from .base_ui import BaseUI \ No newline at end of file diff --git a/amulet_map_editor/programs/edit/canvas/ui/base_ui.py b/amulet_map_editor/programs/edit/canvas/ui/base_ui.py new file mode 100644 index 00000000..5836d9d7 --- /dev/null +++ b/amulet_map_editor/programs/edit/canvas/ui/base_ui.py @@ -0,0 +1,14 @@ +from typing import TYPE_CHECKING +import weakref + +if TYPE_CHECKING: + from amulet_map_editor.programs.edit.canvas.edit_canvas import EditCanvas + + +class BaseUI: + def __init__(self, canvas: "EditCanvas"): + self._canvas = weakref.ref(canvas) + + @property + def canvas(self) -> "EditCanvas": + return self._canvas() diff --git a/amulet_map_editor/programs/edit/canvas/ui/file.py b/amulet_map_editor/programs/edit/canvas/ui/file.py new file mode 100644 index 00000000..70570c1e --- /dev/null +++ b/amulet_map_editor/programs/edit/canvas/ui/file.py @@ -0,0 +1,87 @@ +from typing import TYPE_CHECKING, Optional +import wx + +from .base_ui import BaseUI +from amulet_map_editor.amulet_wx.ui.simple import SimpleChoiceAny +from amulet_map_editor.programs.edit.canvas.events import ( + EVT_CAMERA_MOVE, + EVT_UNDO, + EVT_REDO, + EVT_CREATE_UNDO, + EVT_SAVE +) + +if TYPE_CHECKING: + from amulet_map_editor.programs.edit.canvas.edit_canvas import EditCanvas + + +class FilePanel(wx.BoxSizer, BaseUI): + def __init__(self, canvas: 'EditCanvas'): + wx.BoxSizer.__init__(self, wx.HORIZONTAL) + BaseUI.__init__(self, canvas) + + self._location_button = wx.Button(canvas, label=', '.join([f'{s:.2f}' for s in self.canvas.camera_location])) + self._location_button.Bind(wx.EVT_BUTTON, lambda evt: self.canvas.goto()) + + self.Add(self._location_button, 0, wx.TOP | wx.BOTTOM | wx.RIGHT | wx.CENTER, 5) + + self._dim_options = SimpleChoiceAny(canvas) + self._dim_options.SetItems(self.canvas.world.world_wrapper.dimensions) + self._dim_options.SetValue("overworld") + self._dim_options.Bind(wx.EVT_CHOICE, self._on_dimension_change) + + self.Add(self._dim_options, 0, wx.TOP | wx.BOTTOM | wx.RIGHT | wx.CENTER, 5) + + def create_button(text, operation): + button = wx.Button(canvas, label=text) + button.Bind(wx.EVT_BUTTON, operation) + self.Add(button, 0, wx.TOP | wx.BOTTOM | wx.RIGHT, 5) + return button + + self._undo_button: Optional[wx.Button] = create_button('Undo', lambda evt: self.canvas.undo()) + self._redo_button: Optional[wx.Button] = create_button('Redo', lambda evt: self.canvas.redo()) + self._save_button: Optional[wx.Button] = create_button('Save', lambda evt: self.canvas.save()) + create_button('Close', lambda evt: self.canvas.close()) + self._update_buttons() + + # self.Fit(self) + self.Layout() + + def bind_events(self): + self.canvas.Bind(EVT_CAMERA_MOVE, self._on_camera_move) + self.canvas.Bind(EVT_UNDO, self._on_update_buttons) + self.canvas.Bind(EVT_REDO, self._on_update_buttons) + self.canvas.Bind(EVT_SAVE, self._on_update_buttons) + self.canvas.Bind(EVT_CREATE_UNDO, self._on_update_buttons) + + def _on_update_buttons(self, evt): + self._update_buttons() + evt.Skip() + + def _update_buttons(self): + self._undo_button.SetLabel(f"Undo | {self.canvas.world.chunk_history_manager.undo_count}") + self._redo_button.SetLabel(f"Redo | {self.canvas.world.chunk_history_manager.redo_count}") + self._save_button.SetLabel(f"Save | {self.canvas.world.chunk_history_manager.unsaved_changes}") + + def _on_dimension_change(self, evt): + """Run when the dimension selection is changed by the user.""" + dimension = self._dim_options.GetAny() + if dimension is not None: + self.canvas.dimension = dimension + evt.Skip() + + def _change_dimension(self, evt): + """Run when the dimension attribute in the canvas is changed. + This is run when the user changes the attribute and when it is changed manually in code.""" + dimension = evt.dimension + index = self._dim_options.FindString(dimension) + if not (index == wx.NOT_FOUND or index == self._dim_options.GetSelection()): + self._dim_options.SetSelection(index) + + def _on_camera_move(self, evt): + x, y, z = evt.location + label = f'{x:.2f}, {y:.2f}, {z:.2f}' + old_label = self._location_button.GetLabel() + self._location_button.SetLabel(label) + if len(label) != len(old_label): + self.canvas.Layout() diff --git a/amulet_map_editor/programs/edit/ui/goto.py b/amulet_map_editor/programs/edit/canvas/ui/goto.py similarity index 93% rename from amulet_map_editor/programs/edit/ui/goto.py rename to amulet_map_editor/programs/edit/canvas/ui/goto.py index 257bef0e..44812c79 100644 --- a/amulet_map_editor/programs/edit/ui/goto.py +++ b/amulet_map_editor/programs/edit/canvas/ui/goto.py @@ -1,6 +1,6 @@ from typing import Optional, Tuple import wx -from amulet_map_editor.amulet_wx.simple import SimpleDialog +from amulet_map_editor.amulet_wx.ui.simple import SimpleDialog def show_goto(parent, x: float, y: float, z: float) -> Optional[Tuple[float, float, float]]: diff --git a/amulet_map_editor/programs/edit/canvas/ui/select_location.py b/amulet_map_editor/programs/edit/canvas/ui/select_location.py new file mode 100644 index 00000000..36beff18 --- /dev/null +++ b/amulet_map_editor/programs/edit/canvas/ui/select_location.py @@ -0,0 +1,72 @@ +import wx +import numpy +from typing import Optional, Callable, Type, Any, TYPE_CHECKING + +from amulet.api.structure import Structure +from amulet.api.data_types import BlockCoordinates +from amulet_map_editor.amulet_wx.ui.simple import SimplePanel +from amulet_map_editor.amulet_wx.util.validators import IntValidator +from amulet_map_editor.programs.edit.canvas.ui.base_ui import BaseUI + +if TYPE_CHECKING: + from amulet_map_editor.programs.edit.canvas.edit_canvas import EditCanvas + + +class SelectLocationUI(SimplePanel, BaseUI): + """A UI element that can be dropped into the EditCanvas and let the user pick a location for a structure. + This UI does not allow for rotation. + Will send EVT_SELECT_CONFIRM when the user confirms the selection.""" + def __init__( + self, + parent: wx.Window, + canvas: "EditCanvas", + structure: Structure, + confirm_callback: Callable[[], None] + ): + SimplePanel.__init__(self, parent) + BaseUI.__init__(self, canvas) + + self._structure = structure + self.canvas.structure_locations.clear() + self.canvas.structure_locations.append(numpy.array([0, 0, 0])) + self.canvas.structure = structure + self.canvas.draw_structure = True + + def _add_row(label: str, wx_object: Type[wx.Object], **kwargs) -> Any: + sizer = wx.BoxSizer(wx.HORIZONTAL) + self.add_object(sizer, 0, wx.ALIGN_CENTER_HORIZONTAL) + name_text = wx.StaticText(self, label=label) + sizer.Add(name_text, flag=wx.CENTER | wx.ALL | wx.EXPAND, border=5) + obj = wx_object(self, **kwargs) + sizer.Add(obj, flag=wx.CENTER | wx.ALL, border=5) + return obj + + self._x: wx.SpinCtrl = _add_row('x', wx.SpinCtrl, min=-30000000, max=30000000) + self._y: wx.SpinCtrl = _add_row('y', wx.SpinCtrl, min=-30000000, max=30000000) + self._z: wx.SpinCtrl = _add_row('z', wx.SpinCtrl, min=-30000000, max=30000000) + for ui in (self._x, self._y, self._z): + ui.SetValidator(IntValidator()) + self._copy_air: wx.CheckBox = _add_row('Copy Air', wx.CheckBox) + self._x.Bind(wx.EVT_SPINCTRL, self._on_location_change) + self._y.Bind(wx.EVT_SPINCTRL, self._on_location_change) + self._z.Bind(wx.EVT_SPINCTRL, self._on_location_change) + + self._confirm = wx.Button(self, label="Confirm") + self.sizer.Add(self._confirm, flag=wx.ALIGN_CENTER_HORIZONTAL | wx.ALL, border=5) + + self._confirm.Bind(wx.EVT_BUTTON, lambda evt: confirm_callback()) + + @property + def location(self) -> BlockCoordinates: + return self._x.GetValue(), self._y.GetValue(), self._z.GetValue() + + @property + def copy_air(self) -> bool: + return self._copy_air.GetValue() + + @property + def structure(self) -> Structure: + return self._structure + + def _on_location_change(self, evt): + self.canvas.structure_locations[-1] = numpy.array(self.location) diff --git a/amulet_map_editor/programs/edit/canvas/ui/tool/__init__.py b/amulet_map_editor/programs/edit/canvas/ui/tool/__init__.py new file mode 100644 index 00000000..f3f99636 --- /dev/null +++ b/amulet_map_editor/programs/edit/canvas/ui/tool/__init__.py @@ -0,0 +1 @@ +from .tool import Tool \ No newline at end of file diff --git a/amulet_map_editor/programs/edit/canvas/ui/tool/tool.py b/amulet_map_editor/programs/edit/canvas/ui/tool/tool.py new file mode 100644 index 00000000..2be0379a --- /dev/null +++ b/amulet_map_editor/programs/edit/canvas/ui/tool/tool.py @@ -0,0 +1,91 @@ +import wx +from typing import TYPE_CHECKING, Type, Dict, Optional + +from amulet_map_editor.programs.edit.canvas.ui import BaseUI +from amulet_map_editor.programs.edit.canvas.ui.tool.tools.base_tool_ui import BaseToolUI, BaseToolUIType +from amulet_map_editor.programs.edit.canvas.events import ( + ToolChangeEvent, + EVT_TOOL_CHANGE +) + +from .tools.select import SelectOptions +from .tools.operation import SelectOperationUI +from .tools.import_tool import SelectImportOperationUI +from .tools.export_tool import SelectExportOperationUI + +if TYPE_CHECKING: + from ...edit_canvas import EditCanvas + + +class Tool(wx.BoxSizer, BaseUI): + def __init__(self, canvas: "EditCanvas"): + wx.BoxSizer.__init__(self, wx.VERTICAL) + BaseUI.__init__(self, canvas) + + self._tools: Dict[str, BaseToolUIType] = {} + self._active_tool: Optional[BaseToolUIType] = None + + self._tool_option_sizer = wx.BoxSizer(wx.VERTICAL) + self.Add(self._tool_option_sizer, 1, wx.EXPAND, 0) + + tool_select_sizer = wx.BoxSizer(wx.HORIZONTAL) + tool_select_sizer.AddStretchSpacer(1) + self._tool_select = ToolSelect(canvas) + tool_select_sizer.Add(self._tool_select, 0, wx.EXPAND, 0) + tool_select_sizer.AddStretchSpacer(1) + self.Add(tool_select_sizer, 0, wx.EXPAND, 0) + + self.canvas.Bind(EVT_TOOL_CHANGE, lambda evt: self._enable_tool(evt.tool)) + + self.register_tool("Select", SelectOptions) + self._enable_tool("Select") + self.register_tool("Operation", SelectOperationUI) + self.register_tool("Import", SelectImportOperationUI) + self.register_tool("Export", SelectExportOperationUI) + + def bind_events(self): + for tool in self._tools.values(): + tool.bind_events() + + def register_tool(self, name: str, tool_cls: Type[BaseToolUIType]): + assert issubclass(tool_cls, (wx.Window, wx.Sizer)) and issubclass(tool_cls, BaseToolUI) + self._tool_select.register_tool(name) + tool = tool_cls(self.canvas) + if isinstance(tool, wx.Window): + tool.Hide() + elif isinstance(tool, wx.Sizer): + tool.ShowItems(show=False) + self._tools[name] = tool + self._tool_option_sizer.Add(tool, 1, wx.EXPAND, 0) + + def _enable_tool(self, tool: str): + if tool in self._tools: + if self._active_tool is not None: + self._active_tool.disable() + if isinstance(self._active_tool, wx.Window): + self._active_tool.Hide() + elif isinstance(self._active_tool, wx.Sizer): + self._active_tool.ShowItems(show=False) + self._active_tool = self._tools[tool] + if isinstance(self._active_tool, wx.Window): + self._active_tool.Show() + elif isinstance(self._active_tool, wx.Sizer): + self._active_tool.ShowItems(show=True) + self._active_tool.enable() + self.canvas.Layout() + + +class ToolSelect(wx.Panel, BaseUI): + def __init__(self, canvas: "EditCanvas"): + wx.Panel.__init__(self, canvas) + BaseUI.__init__(self, canvas) + + self._sizer = wx.BoxSizer(wx.HORIZONTAL) + self.SetSizer(self._sizer) + + def register_tool(self, name: str): + button = wx.Button(self, label=name) + self._sizer.Add(button) + self._sizer.Fit(self) + self.Layout() + button.Bind(wx.EVT_BUTTON, lambda evt: wx.PostEvent(self.canvas, ToolChangeEvent(tool=name))) diff --git a/amulet_map_editor/programs/edit/canvas/ui/tool/tools/__init__.py b/amulet_map_editor/programs/edit/canvas/ui/tool/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/amulet_map_editor/programs/edit/canvas/ui/tool/tools/base_operation.py b/amulet_map_editor/programs/edit/canvas/ui/tool/tools/base_operation.py new file mode 100644 index 00000000..48ae1567 --- /dev/null +++ b/amulet_map_editor/programs/edit/canvas/ui/tool/tools/base_operation.py @@ -0,0 +1,67 @@ +import wx +from typing import TYPE_CHECKING, Optional + +from amulet_map_editor.amulet_wx.ui.simple import SimpleChoiceAny +from amulet_map_editor.programs.edit.plugins import OperationUIType, OperationStorageType +from amulet_map_editor.programs.edit.canvas.ui.tool.tools.base_tool_ui import BaseToolUI + +if TYPE_CHECKING: + from amulet_map_editor.programs.edit.canvas import EditCanvas + + +class BaseSelectOperationUI(wx.BoxSizer, BaseToolUI): + def __init__(self, canvas: "EditCanvas"): + wx.BoxSizer.__init__(self, wx.VERTICAL) + BaseToolUI.__init__(self, canvas) + self._active_operation: Optional[OperationUIType] = None + + self._operation_choice = SimpleChoiceAny(self.canvas) + self._operation_choice.SetItems({key: value.name for key, value in self._operations.items()}) + self._operation_choice.Bind(wx.EVT_CHOICE, self._on_operation_change) + self.Add(self._operation_choice) + self._operation_sizer = wx.BoxSizer(wx.VERTICAL) + self.Add(self._operation_sizer) + + # self._operation_change() + + def bind_events(self): + pass + + @property + def _operations(self) -> OperationStorageType: + raise NotImplementedError + + @property + def operation(self) -> str: + return self._operation_choice.GetAny() + + def _on_operation_change(self, evt): + self._operation_change() + evt.Skip() + + def _unload_active_operation(self): + if self._active_operation is not None: + self._active_operation.unload() + if isinstance(self._active_operation, wx.Window): + self._active_operation.Destroy() + elif isinstance(self._active_operation, wx.Sizer): + self._operation_sizer.GetItem(self._active_operation).DeleteWindows() + self._active_operation = None + + def _operation_change(self): + operation_path = self._operation_choice.GetAny() + if operation_path: + operation = self._operations[operation_path] + self.disable() + self._active_operation = operation(self.canvas, self.canvas, self.canvas.world) + self._operation_sizer.Add(self._active_operation, 1, wx.EXPAND) + self.Layout() + + def enable(self): + self._operation_change() + self.canvas.draw_structure = False + self.canvas.draw_selection = True + self.canvas.selection_editable = False + + def disable(self): + self._unload_active_operation() diff --git a/amulet_map_editor/programs/edit/canvas/ui/tool/tools/base_tool_ui.py b/amulet_map_editor/programs/edit/canvas/ui/tool/tools/base_tool_ui.py new file mode 100644 index 00000000..70368a3a --- /dev/null +++ b/amulet_map_editor/programs/edit/canvas/ui/tool/tools/base_tool_ui.py @@ -0,0 +1,16 @@ +import wx +from typing import Union +from ...base_ui import BaseUI + +BaseToolUIType = Union[wx.Window, wx.Sizer, "BaseToolUI"] + + +class BaseToolUI(BaseUI): + def enable(self): + raise NotImplementedError + + def disable(self): + raise NotImplementedError + + def bind_events(self): + raise NotImplementedError diff --git a/amulet_map_editor/programs/edit/canvas/ui/tool/tools/export_tool.py b/amulet_map_editor/programs/edit/canvas/ui/tool/tools/export_tool.py new file mode 100644 index 00000000..b94a128b --- /dev/null +++ b/amulet_map_editor/programs/edit/canvas/ui/tool/tools/export_tool.py @@ -0,0 +1,8 @@ +from .base_operation import BaseSelectOperationUI +from amulet_map_editor.programs.edit import plugins + + +class SelectExportOperationUI(BaseSelectOperationUI): + @property + def _operations(self) -> plugins.OperationStorageType: + return plugins.export_operations diff --git a/amulet_map_editor/programs/edit/canvas/ui/tool/tools/import_tool.py b/amulet_map_editor/programs/edit/canvas/ui/tool/tools/import_tool.py new file mode 100644 index 00000000..5f10062f --- /dev/null +++ b/amulet_map_editor/programs/edit/canvas/ui/tool/tools/import_tool.py @@ -0,0 +1,8 @@ +from .base_operation import BaseSelectOperationUI +from amulet_map_editor.programs.edit import plugins + + +class SelectImportOperationUI(BaseSelectOperationUI): + @property + def _operations(self) -> plugins.OperationStorageType: + return plugins.import_operations diff --git a/amulet_map_editor/programs/edit/canvas/ui/tool/tools/operation.py b/amulet_map_editor/programs/edit/canvas/ui/tool/tools/operation.py new file mode 100644 index 00000000..727f576b --- /dev/null +++ b/amulet_map_editor/programs/edit/canvas/ui/tool/tools/operation.py @@ -0,0 +1,8 @@ +from .base_operation import BaseSelectOperationUI +from amulet_map_editor.programs.edit import plugins + + +class SelectOperationUI(BaseSelectOperationUI): + @property + def _operations(self) -> plugins.OperationStorageType: + return plugins.operations diff --git a/amulet_map_editor/programs/edit/canvas/ui/tool/tools/select.py b/amulet_map_editor/programs/edit/canvas/ui/tool/tools/select.py new file mode 100644 index 00000000..2e6d689e --- /dev/null +++ b/amulet_map_editor/programs/edit/canvas/ui/tool/tools/select.py @@ -0,0 +1,155 @@ +from typing import TYPE_CHECKING, Type, Any, Optional +import wx + +from amulet.operations.paste import paste_iter + +from amulet_map_editor.amulet_wx.util.validators import IntValidator +from amulet_map_editor.programs.edit.canvas.ui.tool.tools.base_tool_ui import BaseToolUI +from amulet_map_editor.programs.edit.canvas.ui.select_location import SelectLocationUI +from amulet_map_editor.programs.edit.canvas.events import ( + EVT_PASTE, + EVT_BOX_POINT_1_CHANGE, + EVT_BOX_POINT_2_CHANGE, + EVT_BOX_EDIT_TOGGLE, +) + +if TYPE_CHECKING: + from amulet_map_editor.programs.edit.canvas.edit_canvas import EditCanvas + + +class SelectOptions(wx.BoxSizer, BaseToolUI): + def __init__(self, canvas: 'EditCanvas'): + wx.BoxSizer.__init__(self, wx.HORIZONTAL) + BaseToolUI.__init__(self, canvas) + + self._button_panel = wx.Panel(canvas) + button_sizer = wx.BoxSizer(wx.VERTICAL) + self._button_panel.SetSizer(button_sizer) + delete_button = wx.Button(self._button_panel, label="Delete") + button_sizer.Add(delete_button, 0, wx.ALL | wx.ALIGN_CENTER_HORIZONTAL | wx.EXPAND, 5) + delete_button.Bind(wx.EVT_BUTTON, lambda evt: self.canvas.delete()) + copy_button = wx.Button(self._button_panel, label="Copy") + button_sizer.Add(copy_button, 0, wx.ALL | wx.ALIGN_CENTER_HORIZONTAL | wx.EXPAND, 5) + copy_button.Bind(wx.EVT_BUTTON, lambda evt: self.canvas.copy()) + cut_button = wx.Button(self._button_panel, label="Cut") + button_sizer.Add(cut_button, 0, wx.ALL | wx.ALIGN_CENTER_HORIZONTAL | wx.EXPAND, 5) + cut_button.Bind(wx.EVT_BUTTON, lambda evt: self.canvas.cut()) + paste_button = wx.Button(self._button_panel, label="Paste") + button_sizer.Add(paste_button, 0, wx.ALL | wx.ALIGN_CENTER_HORIZONTAL | wx.EXPAND, 5) + paste_button.Bind(wx.EVT_BUTTON, lambda evt: self.canvas.paste()) + self.Add(self._button_panel, 0, wx.ALIGN_CENTER_VERTICAL) + + self._paste_panel: Optional[SelectLocationUI] = None + + self._x1: wx.SpinCtrl = self._add_row('x1', wx.SpinCtrl, min=-30000000, max=30000000) + self._y1: wx.SpinCtrl = self._add_row('y1', wx.SpinCtrl, min=-30000000, max=30000000) + self._z1: wx.SpinCtrl = self._add_row('z1', wx.SpinCtrl, min=-30000000, max=30000000) + self._x1.Bind(wx.EVT_SPINCTRL, self._green_corner_input_change) + self._y1.Bind(wx.EVT_SPINCTRL, self._green_corner_input_change) + self._z1.Bind(wx.EVT_SPINCTRL, self._green_corner_input_change) + self._x1.SetValidator(IntValidator()) + self._y1.SetValidator(IntValidator()) + self._z1.SetValidator(IntValidator()) + + self._x2: wx.SpinCtrl = self._add_row('x2', wx.SpinCtrl, min=-30000000, max=30000000) + self._y2: wx.SpinCtrl = self._add_row('y2', wx.SpinCtrl, min=-30000000, max=30000000) + self._z2: wx.SpinCtrl = self._add_row('z2', wx.SpinCtrl, min=-30000000, max=30000000) + self._x2.Bind(wx.EVT_SPINCTRL, self._blue_corner_input_change) + self._y2.Bind(wx.EVT_SPINCTRL, self._blue_corner_input_change) + self._z2.Bind(wx.EVT_SPINCTRL, self._blue_corner_input_change) + self._x2.SetValidator(IntValidator()) + self._y2.SetValidator(IntValidator()) + self._z2.SetValidator(IntValidator()) + + self._x1.Disable() + self._y1.Disable() + self._z1.Disable() + self._x2.Disable() + self._y2.Disable() + self._z2.Disable() + + self._x1.SetBackgroundColour((160, 215, 145)) + self._y1.SetBackgroundColour((160, 215, 145)) + self._z1.SetBackgroundColour((160, 215, 145)) + + self._x2.SetBackgroundColour((150, 150, 215)) + self._y2.SetBackgroundColour((150, 150, 215)) + self._z2.SetBackgroundColour((150, 150, 215)) + + self._canvas().Bind(EVT_BOX_POINT_1_CHANGE, self._green_corner_renderer_change) + self._canvas().Bind(EVT_BOX_POINT_2_CHANGE, self._blue_corner_renderer_change) + self._canvas().Bind(EVT_BOX_EDIT_TOGGLE, self._enable_scrolls) + + def bind_events(self): + self.canvas.Bind(EVT_PASTE, self._paste) + + def _remove_paste(self): + if self._paste_panel is not None: + self._paste_panel.Destroy() + self._paste_panel = None + + def _paste(self, evt): + structure = evt.structure + self._button_panel.Hide() + self._remove_paste() + self._paste_panel = SelectLocationUI(self.canvas, self.canvas, structure, self._paste_confirm) + self.Add(self._paste_panel, 0, wx.ALIGN_CENTER_VERTICAL) + self.Layout() + + def _paste_confirm(self): + self.canvas.run_operation( + lambda: paste_iter( + self.canvas.world, + self.canvas.dimension, + self._paste_panel.structure, + [self._paste_panel.location], + self._paste_panel.copy_air + ) + ) + + def enable(self): + self._remove_paste() + self._button_panel.Show() + self.Layout() + self.canvas.draw_structure = False + self.canvas.draw_selection = True + self.canvas.selection_editable = True + + def disable(self): + self._remove_paste() + + def _add_row(self, label: str, wx_object: Type[wx.Object], **kwargs) -> Any: + sizer = wx.BoxSizer(wx.HORIZONTAL) + self._button_panel.GetSizer().Add(sizer, 0, 0) + name_text = wx.StaticText(self._button_panel, label=label) + sizer.Add(name_text, flag=wx.CENTER | wx.ALL | wx.EXPAND, border=5) + obj = wx_object(self._button_panel, **kwargs) + sizer.Add(obj, flag=wx.CENTER | wx.ALL, border=5) + return obj + + def _green_corner_input_change(self, _): + self._canvas().active_selection.point1 = [self._x1.GetValue(), self._y1.GetValue(), self._z1.GetValue()] + + def _blue_corner_input_change(self, _): + self._canvas().active_selection.point2 = [self._x2.GetValue(), self._y2.GetValue(), self._z2.GetValue()] + + def _green_corner_renderer_change(self, evt): + x, y, z = evt.location + self._x1.SetValue(x) + self._y1.SetValue(y) + self._z1.SetValue(z) + + def _blue_corner_renderer_change(self, evt): + x, y, z = evt.location + self._x2.SetValue(x) + self._y2.SetValue(y) + self._z2.SetValue(z) + + def _enable_scrolls(self, evt): + enabled = not evt.edit + self._x1.Enable(enabled) + self._y1.Enable(enabled) + self._z1.Enable(enabled) + self._x2.Enable(enabled) + self._y2.Enable(enabled) + self._z2.Enable(enabled) diff --git a/amulet_map_editor/programs/edit/edit.py b/amulet_map_editor/programs/edit/edit.py index 162b8290..e8d6b9b8 100644 --- a/amulet_map_editor/programs/edit/edit.py +++ b/amulet_map_editor/programs/edit/edit.py @@ -1,230 +1,133 @@ import wx -from typing import TYPE_CHECKING, Optional, List, Callable, Any -from types import GeneratorType +from typing import TYPE_CHECKING, Optional, Callable import webbrowser -import time -import traceback -import os -from amulet.api.selection import SelectionGroup, SelectionBox -from amulet.api.structure import Structure -from amulet.api.data_types import OperationType, OperationReturnType +EDIT_CONFIG_ID = "amulet_edit" -from amulet_map_editor import log, CONFIG +from amulet_map_editor import CONFIG, log from amulet_map_editor.programs import BaseWorldProgram, MenuData -from amulet_map_editor import plugins -from amulet_map_editor.amulet_wx.key_config import KeyConfigDialog - - -from .canvas.controllable_canvas import ControllableEditCanvas -from .ui.file import FilePanel -from .ui.tool_options.operation import OperationUI -from .ui.tool_options.select import SelectOptions -from .ui.tool import ToolSelect -from .key_config import DefaultKeys, DefaultKeybindGroupId, PresetKeybinds, KeybindKeys - -from .events import ( - EVT_CAMERA_MOVE, - EVT_SELECT_TOOL_ENABLED, - EVT_OPERATION_TOOL_ENABLED, - EVT_IMPORT_TOOL_ENABLED, - EVT_EXPORT_TOOL_ENABLED, -) +from amulet_map_editor.amulet_wx.util.key_config import KeyConfigDialog +from amulet_map_editor.programs.edit.canvas.events import EVT_EDIT_CLOSE +from .canvas.edit_canvas import EditCanvas +from .key_config import DefaultKeybindGroupId, PresetKeybinds, KeybindKeys if TYPE_CHECKING: from amulet.api.world import World -EDIT_CONFIG_ID = "amulet_edit" - - -def show_loading_dialog( - run: Callable[[], OperationReturnType], title: str, message: str, parent: wx.Window -) -> Any: - dialog = wx.ProgressDialog( - title, - message, - parent=parent, - style=wx.PD_APP_MODAL - | wx.PD_ELAPSED_TIME - | wx.PD_REMAINING_TIME - | wx.PD_AUTO_HIDE, - ) - t = time.time() - try: - obj = run() - if isinstance(obj, GeneratorType): - try: - while True: - progress = next(obj) - if isinstance(progress, (list, tuple)): - if len(progress) >= 2: - message = progress[1] - if len(progress) >= 1: - progress = progress[0] - if isinstance(progress, (int, float)) and isinstance(message, str): - dialog.Update(min(99.99, max(0, progress)), message) - except StopIteration as e: - obj = e.value - except Exception as e: - dialog.Destroy() - raise e - time.sleep(max(0.2 - time.time() + t, 0)) - dialog.Destroy() - return obj - class EditExtension(wx.Panel, BaseWorldProgram): - def __init__(self, parent, world: "World"): + def __init__(self, parent, world: "World", close_self_callback: Callable[[], None]): wx.Panel.__init__(self, parent) self._sizer = wx.BoxSizer(wx.VERTICAL) + self.SetBackgroundColour(tuple(int(v*255) for v in EditCanvas.background_colour)) self.SetSizer(self._sizer) self._world = world - self._canvas: Optional[ControllableEditCanvas] = None + self._canvas: Optional[EditCanvas] = None + self._close_self_callback = close_self_callback self._temp = wx.StaticText(self, label="Please wait while the renderer loads") self._temp.SetFont(wx.Font(40, wx.DECORATIVE, wx.NORMAL, wx.NORMAL)) self._sizer.Add(self._temp) - self._file_panel: Optional[FilePanel] = None - self._select_options: Optional[SelectOptions] = None - self._operation_options: Optional[OperationUI] = None - self._tool_panel: Optional[ToolSelect] = None - - self.Bind(wx.EVT_SIZE, self._on_resize) - def enable(self): if self._canvas is None: self.Update() - self._canvas = ControllableEditCanvas(self, self._world) - - config = CONFIG.get(EDIT_CONFIG_ID, {}) - user_keybinds = config.get("user_keybinds", {}) - group = config.get("keybind_group", DefaultKeybindGroupId) - if group in user_keybinds: - keybinds = user_keybinds[group] - elif group in PresetKeybinds: - keybinds = PresetKeybinds[group] - else: - keybinds = DefaultKeys - self._canvas.set_key_binds( - keybinds - ) - - self._file_panel = FilePanel( - self._canvas, - self._world, - self._undo, - self._redo, - self._save_world, - self._close_world, - ) - self._select_options = SelectOptions(self._canvas) - self._operation_options = OperationUI( - self._canvas, self._world, self._run_operation_event, self._run_main_operation - ) - self._tool_panel = ToolSelect(self._canvas) - - self._canvas.Bind(EVT_CAMERA_MOVE, self._file_panel.move_event) - self._tool_panel.Bind(EVT_SELECT_TOOL_ENABLED, self.show_select_options) - self._tool_panel.Bind( - EVT_OPERATION_TOOL_ENABLED, self.show_operation_options - ) - self._tool_panel.Bind(EVT_IMPORT_TOOL_ENABLED, self.show_import_options) - self._tool_panel.Bind(EVT_EXPORT_TOOL_ENABLED, self.show_export_options) - - self._file_panel.Hide() - self._select_options.Hide() - self._operation_options.Hide() - self._tool_panel.Hide() - + self._canvas = EditCanvas(self, self._world) self._sizer.Add(self._canvas, 1, wx.EXPAND) - - canvas_sizer = wx.BoxSizer(wx.VERTICAL) - self._canvas.SetSizer(canvas_sizer) - bottom_sizer0 = wx.BoxSizer(wx.HORIZONTAL) - middle_sizer0 = wx.BoxSizer(wx.VERTICAL) - top_sizer0 = wx.BoxSizer(wx.HORIZONTAL) - top_sizer0.AddStretchSpacer(1) - top_sizer0.Add(self._file_panel, 0, wx.EXPAND, 0) - canvas_sizer.Add(top_sizer0, 0, wx.EXPAND, 0) - middle_sizer0.AddStretchSpacer(1) - middle_sizer0.Add(self._select_options, 0, 0, 0) - middle_sizer0.Add(self._operation_options, 0, 0, 0) - middle_sizer0.AddStretchSpacer(1) - canvas_sizer.Add(middle_sizer0, 1, wx.EXPAND, 0) - bottom_sizer0.AddStretchSpacer(1) - bottom_sizer0.Add(self._tool_panel, 0, wx.EXPAND, 0) - bottom_sizer0.AddStretchSpacer(1) - canvas_sizer.Add(bottom_sizer0, 0, wx.EXPAND, 0) - + self.Bind(wx.EVT_SIZE, self._on_resize) + self._canvas.Bind(EVT_EDIT_CLOSE, self._on_close) self._temp.Destroy() - self._file_panel.Show() - self._select_options.Show() - self._tool_panel.Show() + self._canvas.Show() + self._canvas.draw() self.Layout() self._canvas.Update() self._canvas.enable() self._canvas.set_size(self.GetSize()[0], self.GetSize()[1]) self._canvas.draw() - self._file_panel.change_dimension() def disable(self): if self._canvas is not None: self._canvas.disable() + def _on_close(self, evt: EVT_EDIT_CLOSE): + if self.is_closeable(): + self._close_self_callback() + def close(self): + """Fully close the UI. Called when destroying the UI.""" self.disable() if self._canvas is not None: self._canvas.close() - def is_closeable(self): + def is_closeable(self) -> bool: + """ + Check if it is safe to close the UI. + :return: True if the program can be closed, False otherwise + """ if self._canvas is not None: - return self._canvas.is_closeable() and not bool( - self._world.chunk_history_manager.unsaved_changes - ) + if self._canvas.is_closeable(): + return self._check_close_world() + log.info(f"The canvas in edit for world {self._world.world_wrapper.world_name} was not closeable for some reason.") + return False return not bool(self._world.chunk_history_manager.unsaved_changes) - def _close_world(self, _): + def _check_close_world(self) -> bool: + """ + Check if it is safe to close the world and prompt the user if it is not. + :return: True if the world can be closed, False otherwise + """ unsaved_changes = self._world.chunk_history_manager.unsaved_changes if unsaved_changes: msg = wx.MessageDialog( self, - f"There {'is' if unsaved_changes == 1 else 'are'} {unsaved_changes} unsaved change{'s' if unsaved_changes >= 2 else ''}. Would you like to save?", + f"""There { + 'is' if unsaved_changes == 1 else 'are' + } {unsaved_changes} unsaved change{ + 's' if unsaved_changes >= 2 else '' + } in { + self._world.world_wrapper.world_name + }. Would you like to save?""", style=wx.YES_NO | wx.CANCEL | wx.CANCEL_DEFAULT, ) response = msg.ShowModal() if response == wx.ID_YES: - self._save_world() + self._canvas.save() + return True + elif response == wx.ID_NO: + return True elif response == wx.ID_CANCEL: - return - self.GetGrandParent().GetParent().close_world(self._world.world_path) + log.info(f"""Aborting closing world { + self._world.world_wrapper.world_name + } because the user pressed cancel.""") + return False + return True def menu(self, menu: MenuData) -> MenuData: menu.setdefault("&File", {}).setdefault("system", {}).setdefault( - "Save\tCtrl+s", lambda evt: self._save_world() + "Save\tCtrl+s", lambda evt: self._canvas.save() ) # menu.setdefault('&File', {}).setdefault('system', {}).setdefault('Save As', lambda evt: self.GetGrandParent().close_world(self.world.world_path)) menu.setdefault("&Edit", {}).setdefault("shortcut", {}).setdefault( - "Undo\tCtrl+z", lambda evt: self._undo() + "Undo\tCtrl+z", lambda evt: self._canvas.undo() ) menu.setdefault("&Edit", {}).setdefault("shortcut", {}).setdefault( - "Redo\tCtrl+y", lambda evt: self._redo() + "Redo\tCtrl+y", lambda evt: self._canvas.redo() ) menu.setdefault("&Edit", {}).setdefault("shortcut", {}).setdefault( - "Cut\tCtrl+x", lambda evt: self._cut() + "Cut\tCtrl+x", lambda evt: self._canvas.cut() ) menu.setdefault("&Edit", {}).setdefault("shortcut", {}).setdefault( - "Copy\tCtrl+c", lambda evt: self._copy() + "Copy\tCtrl+c", lambda evt: self._canvas.copy() ) menu.setdefault("&Edit", {}).setdefault("shortcut", {}).setdefault( - "Paste\tCtrl+v", lambda evt: self._paste() + "Paste\tCtrl+v", lambda evt: self._canvas.paste() ) menu.setdefault("&Edit", {}).setdefault("shortcut", {}).setdefault( - "Delete\tDelete", lambda evt: self._delete() + "Delete\tDelete", lambda evt: self._canvas.delete() ) menu.setdefault("&Edit", {}).setdefault("shortcut", {}).setdefault( - "Goto\tCtrl+g", lambda evt: self._file_panel.show_goto() + "Goto\tCtrl+g", lambda evt: self._canvas.goto() ) menu.setdefault("&Options", {}).setdefault("options", {}).setdefault( "Controls...", lambda evt: self._edit_controls() @@ -256,201 +159,3 @@ def _on_resize(self, event): if self._canvas is not None: self._canvas.set_size(self.GetSize()[0], self.GetSize()[1]) event.Skip() - - def _undo(self, *_): - self._world.undo() - self._file_panel.update_buttons() - - def _redo(self, *_): - self._world.redo() - self._file_panel.update_buttons() - - def _save_world(self, *_): - self._canvas.disable_threads() - - def save(): - for chunk_index, chunk_count in self._world.save_iter(): - yield 100 * chunk_index / chunk_count - - show_loading_dialog(lambda: save(), f"Saving world.", "Please wait.", self) - self._file_panel.update_buttons() - self._canvas.enable_threads() - - def _get_box(self) -> Optional[SelectionGroup]: - group = self._canvas.selection_group - if group.selection_boxes: - return group - else: - wx.MessageBox( - "You must select an area of the world before running this operation" - ) - return None - - def _run_operation_event(self, evt): - self._run_operation() - evt.Skip() - - def _run_operation(self, operation_path=None) -> Any: - if operation_path is None: - operation_path = self._operation_options.operation - operation = plugins.all_operations[operation_path] - features = operation.get("features", []) - operation_input_definitions = operation.get("inputs", []) - if any(feature in features for feature in ("dst_location_absolute",)): - if "structure_callable" in operation: - operation_inputs = [] - for inp in operation.get("structure_callable_inputs", []): - if inp == "src_selection": - selection = self._get_box() - if selection is None: - return - operation_inputs.append(selection) - - elif inp == "options": - operation_inputs.append( - plugins.options.get(operation_path, {}) - ) - - self._operation_options.Disable() - - self._canvas.disable_threads() - try: - structure = show_loading_dialog( - lambda: operation["structure_callable"]( - self._world, self._canvas.dimension, *operation_inputs - ), - f'Running structure operation for {operation["name"]}.', - "Please wait for the operation to finish.", - self, - ) - except Exception as e: - log.error(f"Error running structure operation: {e}\n{traceback.format_exc()}") - wx.MessageBox(f"Error running structure operation: {e}") - self._world.restore_last_undo_point() - self._canvas.enable_threads() - self._operation_options.Enable() - return - self._canvas.enable_threads() - - self._operation_options.Enable() - if not isinstance(structure, Structure): - log.error("Object returned from structure_callable was not a Structure. Aborting.") - wx.MessageBox( - "Object returned from structure_callable was not a Structure. Aborting." - ) - return - else: - selection = self._get_box() - if selection is None: - return - self._operation_options.Disable() - structure = show_loading_dialog( - lambda: Structure.from_world( - self._world, selection, self._canvas.dimension - ), - f'Running structure operation for {operation["name"]}.', - "Copying structure from world.", - self, - ) - self._operation_options.Enable() - - if "dst_location_absolute" in features: - # trigger UI to show select box UI - self._operation_options.enable_select_destination_ui( - operation_path, - operation["operation"], - operation_input_definitions, - structure, - plugins.options.get(operation_path, {}), - ) - else: - # trigger UI to show select box multiple UI - raise NotImplementedError - - else: - self._operation_options.Disable() - out = self._run_main_operation( - operation_path, operation["operation"], operation_input_definitions - ) - self._operation_options.Enable() - return out - - def _run_main_operation( - self, - operation_path: str, - operation: OperationType, - operation_input_definitions: List[str], - options=None, - structure=None, - ) -> Any: - operation_inputs = [] - for inp in operation_input_definitions: - if inp == "src_selection": - selection = self._get_box() - if selection is None: - return - operation_inputs.append(selection) - elif inp == "structure": - operation_inputs.append(structure) - elif inp == "options": - if options: - plugins.options[operation_path] = options - operation_inputs.append(options) - else: - operation_inputs.append(plugins.options.get(operation_path, {})) - - self._canvas.disable_threads() - try: - out = show_loading_dialog( - lambda: operation( - self._world, self._canvas.dimension, *operation_inputs - ), - f"Running Operation ?.", - "Please wait for the operation to finish.", - self, - ) - self._world.create_undo_point() - self._file_panel.update_buttons() - except Exception as e: - operation_info = plugins.all_operations[operation_path] - log.error( - f'Error occurred while running operation: {operation_info["name"]} v{operation_info["v"]}' - ) - log.error(f"{e}\n{traceback.format_exc()}") - wx.MessageBox(f"Error running operation: {e}") - self._world.restore_last_undo_point() - out = None - self._canvas.enable_threads() - return out - - def _cut(self) -> bool: - return self._run_operation(os.path.join(os.path.dirname(plugins.__file__), 'internal_operations', 'cut.py')) - - def _copy(self) -> bool: - return self._run_operation(os.path.join(os.path.dirname(plugins.__file__), 'internal_operations', 'copy.py')) - - def _paste(self) -> bool: - self.show_operation_options(None) - return self._run_operation(os.path.join(os.path.dirname(plugins.__file__), 'internal_operations', 'paste.py')) - - def _delete(self) -> bool: - return self._run_operation(os.path.join(os.path.dirname(plugins.__file__), 'internal_operations', 'delete.py')) - - def show_select_options(self, _): - self._operation_options.Hide() - self._select_options.enable() - self.Layout() - - def _show_operation_options(self, enable: Callable): - self._select_options.Hide() - enable() - self.Layout() - - def show_operation_options(self, _): - self._show_operation_options(self._operation_options.enable_operation_ui) - - def show_import_options(self, _): - self._show_operation_options(self._operation_options.enable_import_ui) - - def show_export_options(self, _): - self._show_operation_options(self._operation_options.enable_export_ui) diff --git a/amulet_map_editor/programs/edit/events.py b/amulet_map_editor/programs/edit/events.py deleted file mode 100644 index 40b9ea3f..00000000 --- a/amulet_map_editor/programs/edit/events.py +++ /dev/null @@ -1,13 +0,0 @@ -from wx.lib import newevent - -CameraMoveEvent, EVT_CAMERA_MOVE = newevent.NewEvent() - -SelectToolEnabledEvent, EVT_SELECT_TOOL_ENABLED = newevent.NewEvent() -OperationToolEnabledEvent, EVT_OPERATION_TOOL_ENABLED = newevent.NewEvent() -ImportToolEnabledEvent, EVT_IMPORT_TOOL_ENABLED = newevent.NewEvent() -ExportToolEnabledEvent, EVT_EXPORT_TOOL_ENABLED = newevent.NewEvent() - -BoxGreenCornerChangeEvent, EVT_BOX_GREEN_CORNER_CHANGE = newevent.NewEvent() -BoxBlueCornerChangeEvent, EVT_BOX_BLUE_CORNER_CHANGE = newevent.NewEvent() - -BoxCoordsEnableEvent, EVT_BOX_COORDS_ENABLE = newevent.NewEvent() diff --git a/amulet_map_editor/programs/edit/key_config.py b/amulet_map_editor/programs/edit/key_config.py index 2f262bdd..fca9855a 100644 --- a/amulet_map_editor/programs/edit/key_config.py +++ b/amulet_map_editor/programs/edit/key_config.py @@ -1,9 +1,9 @@ from typing import List -from amulet_map_editor.amulet_wx.key_config import ( +from amulet_map_editor.amulet_wx.util.key_config import ( KeybindContainer, KeybindGroup, KeybindGroupIdType, - KeybindIdType, + KeyActionType, Space, Shift, MouseLeft, @@ -15,7 +15,7 @@ ) -KeybindKeys: List[KeybindIdType] = [ +KeybindKeys: List[KeyActionType] = [ "up", "down", "forwards", diff --git a/amulet_map_editor/programs/edit/plugins/__init__.py b/amulet_map_editor/programs/edit/plugins/__init__.py new file mode 100644 index 00000000..061e5ebb --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/__init__.py @@ -0,0 +1,5 @@ +from .api.data_types import PathType, OperationStorageType +from .api.operation_ui import OperationUI, OperationUIType +from .api.fixed_pipeline import FixedFunctionUI +from .api.errors import OperationError, OperationSuccessful, OperationSilentAbort +from .api.loader import all_operations, internal_operations, operations, export_operations, import_operations, reload_operations diff --git a/amulet_map_editor/programs/edit/plugins/api/__init__.py b/amulet_map_editor/programs/edit/plugins/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/amulet_map_editor/programs/edit/plugins/api/data_types.py b/amulet_map_editor/programs/edit/plugins/api/data_types.py new file mode 100644 index 00000000..b7269f7e --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/api/data_types.py @@ -0,0 +1,7 @@ +from typing import Dict, TYPE_CHECKING + +if TYPE_CHECKING: + from .loader import OperationLoader + +PathType = str +OperationStorageType = Dict[PathType, "OperationLoader"] diff --git a/amulet_map_editor/programs/edit/plugins/api/errors.py b/amulet_map_editor/programs/edit/plugins/api/errors.py new file mode 100644 index 00000000..71cde404 --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/api/errors.py @@ -0,0 +1,24 @@ +class BaseOperationException(Exception): + pass + + +class OperationError(BaseOperationException): + """Error to raise if something went wrong when running the operation. + Will notify the user with a dialog box with the message f"Error running operation {msg}" + Changes will be rolled back to the last backup. + Eg. if the operation requires something it is not given""" + pass + + +class OperationSuccessful(BaseOperationException): + """raise this if you want to exit the operation without creating an undo point. + Will notify the user with a dialog box containing the message. + Changes will be rolled back to the last backup.""" + pass + + +class OperationSilentAbort(BaseOperationException): + """Error to raise if something went wrong when running the operation but you do not want the user to see. + This error will be handled silently. + Changes will be rolled back to the last backup.""" + pass diff --git a/amulet_map_editor/programs/edit/plugins/api/fixed_pipeline.py b/amulet_map_editor/programs/edit/plugins/api/fixed_pipeline.py new file mode 100644 index 00000000..a2156139 --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/api/fixed_pipeline.py @@ -0,0 +1,222 @@ +import wx +from typing import Callable, Dict, Any, TYPE_CHECKING, Sequence + +from amulet_map_editor.amulet_wx.util.validators import IntValidator + +from amulet.api.data_types import OperationReturnType +from .operation_ui import OperationUI + +if TYPE_CHECKING: + from amulet_map_editor.programs.edit.canvas.edit_canvas import EditCanvas + from amulet.api.world import World + +FixedOperationType = Callable[["World", "Dimension", "SelectionGroup", Dict[str, Any]], OperationReturnType] + + +class FixedFunctionUI(wx.Panel, OperationUI): + def __init__( + self, + parent: wx.Window, + canvas: "EditCanvas", + world: "World", + options_path: str, + operation: FixedOperationType, + options: Dict[str, Any] + ): + wx.Panel.__init__(self, parent) + OperationUI.__init__(self, parent, canvas, world, options_path) + self._operation = operation + + self.Hide() + self._sizer = wx.BoxSizer(wx.VERTICAL) + self.SetSizer(self._sizer) + self._options_sizer = wx.BoxSizer(wx.VERTICAL) + self._sizer.Add(self._options_sizer) + self._run_button = wx.Button(self, label="Run Operation") + self._run_button.Bind(wx.EVT_BUTTON, self._run_operation) + self._sizer.Add(self._run_button, 0, wx.ALL | wx.ALIGN_CENTRE_HORIZONTAL, 5) + + self._options: Dict[str, wx.Window] = {} + self._create_options(options) + + self.Layout() + self.Show() + + def unload(self): + pass + + def _create_options(self, options: Dict[str, Sequence]): + create_functions: Dict[str, Callable[[str, Sequence], None]] = { + "label": self._create_label, + "bool": self._create_bool, + "int": self._create_int, + "float": self._create_float, + "str": self._create_string, + "str_choice": self._create_str_choice, + "file_open": self._create_file_open_picker, + "file_save": self._create_file_save_picker, + "directory": self._create_directory_picker + } + for option_name, option in options.items(): + if not (isinstance(option, (list, tuple)) and option): + continue + option_type, option = option[0], option[1:] + if option_type not in create_functions: + continue + create_functions[option_type](option_name, option) + + def _create_label(self, option_name: str, options: Sequence): + label = wx.StaticText(self, label=option_name) + self._options_sizer.Add(label, 0, wx.ALL | wx.ALIGN_CENTRE_HORIZONTAL, 5) + + def _create_horizontal_options_sizer(self, label) -> wx.BoxSizer: + sizer = wx.BoxSizer(wx.HORIZONTAL) + label = wx.StaticText(self, label=label) + sizer.Add(label, 0, wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 5) + self._options_sizer.Add(sizer, 0, wx.ALL | wx.ALIGN_CENTRE_HORIZONTAL, 5) + return sizer + + def _create_bool(self, option_name: str, options: Sequence): + if options and not isinstance(options[0], bool): + return + sizer = self._create_horizontal_options_sizer(option_name) + option = wx.CheckBox(self) + sizer.Add(option) + if options: + option.SetValue(options[0]) + self._options[option_name] = option + + def _create_int(self, option_name: str, options: Sequence): + if len(options) in {0, 1, 3} and all(isinstance(o, int) for o in options): + sizer = self._create_horizontal_options_sizer(option_name) + if len(options) == 0: + option = wx.SpinCtrl( + self, + min=-30_000_000, + max=30_000_000, + initial=0, + ) + elif len(options) == 1: + option = wx.SpinCtrl( + self, + min=-30_000_000, + max=30_000_000, + initial=options[0], + ) + elif len(options) == 3: + option = wx.SpinCtrl( + self, + min=min(options[1:3]), + max=max(options[1:3]), + initial=options[0], + ) + else: + return # should not get here + option.SetValidator(IntValidator()) + sizer.Add(option) + self._options[option_name] = option + + def _create_float(self, option_name: str, options: Sequence): + if len(options) in {0, 1, 3} and all(isinstance(o, (int, float)) for o in options): + sizer = self._create_horizontal_options_sizer(option_name) + if len(options) == 0: + option = wx.SpinCtrlDouble( + self, + min=-30_000_000, + max=30_000_000, + initial=0, + ) + elif len(options) == 1: + option = wx.SpinCtrlDouble( + self, + min=-30_000_000, + max=30_000_000, + initial=options[0], + ) + elif len(options) == 3: + option = wx.SpinCtrlDouble( + self, + min=min(options[1:3]), + max=max(options[1:3]), + initial=options[0], + ) + else: + return # should not get here + sizer.Add(option) + self._options[option_name] = option + + def _create_string(self, option_name: str, options: Sequence): + if options and not isinstance(options[0], str): + return + sizer = self._create_horizontal_options_sizer(option_name) + option = wx.TextCtrl(self) + sizer.Add(option) + if options: + option.SetValue(options[0]) + self._options[option_name] = option + + def _create_str_choice(self, option_name: str, options: Sequence): + if not (options and all(isinstance(o, str) for o in options)): + return + sizer = self._create_horizontal_options_sizer(option_name) + option = wx.Choice(self, choices=options) + option.SetSelection(0) + sizer.Add(option) + self._options[option_name] = option + + def _create_file_save_picker(self, option_name: str, options: Sequence): + sizer = self._create_horizontal_options_sizer(option_name) + option = wx.FilePickerCtrl( + self, + style=wx.FLP_SAVE | wx.FLP_USE_TEXTCTRL + ) + sizer.Add(option) + self._options[option_name] = option + + def _create_file_open_picker(self, option_name: str, options: Sequence): + sizer = self._create_horizontal_options_sizer(option_name) + option = wx.FilePickerCtrl( + self, + style=wx.FLP_OPEN | wx.FLP_USE_TEXTCTRL + ) + sizer.Add(option) + self._options[option_name] = option + + def _create_directory_picker(self, option_name: str, options: Sequence): + sizer = self._create_horizontal_options_sizer(option_name) + option = wx.DirPickerCtrl( + self, + style=wx.DIRP_USE_TEXTCTRL + ) + sizer.Add(option) + self._options[option_name] = option + + def _get_values(self) -> Dict[str, Any]: + options = {} + for key, window in self._options.items(): + if isinstance( + window, + ( + wx.CheckBox, + wx.SpinCtrl, + wx.SpinCtrlDouble, + wx.TextCtrl, + + ) + ): + options[key] = window.GetValue() + elif isinstance(window, wx.Choice): + options[key] = window.GetString(window.GetSelection()) + elif isinstance(window, (wx.FilePickerCtrl, wx.DirPickerCtrl)): + options[key] = window.GetPath() + return options + + def _run_operation(self, evt): + self.canvas.run_operation( + lambda: self._operation( + self.world, + self.canvas.dimension, + self.canvas.selection_group, + self._get_values() + ) + ) diff --git a/amulet_map_editor/programs/edit/plugins/api/loader.py b/amulet_map_editor/programs/edit/plugins/api/loader.py new file mode 100644 index 00000000..f7603081 --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/api/loader.py @@ -0,0 +1,212 @@ +import os +import glob +import importlib.util +from amulet import log +from typing import Dict, List, TYPE_CHECKING, Callable, Optional, Type +import wx +import struct +import hashlib +import inspect +import string + +from .fixed_pipeline import FixedFunctionUI +from .operation_ui import OperationUI, OperationUIType +from .data_types import OperationStorageType + +if TYPE_CHECKING: + from amulet_map_editor.programs.edit.canvas import EditCanvas + from amulet.api.world import World + + +STOCK_PLUGINS_DIR = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), "stock_plugins")) +CUSTOM_PLUGINS_DIR = os.path.abspath("plugins") + +ValidChrs = set("-_.() abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + + +class OperationLoadException(Exception): + """Exception for internal use""" + pass + + +class OperationLoader: + """A class to handle loading and reloading operations from their python modules/packages""" + def __init__( + self, + export_dict: dict, + path: str + ): + self._path = path + self._name = "" + self._ui: Optional[Callable[[wx.Window, "EditCanvas", "World"], OperationUI]] = None + self._load(export_dict) + + def _load(self, export_dict: dict): + if not isinstance(export_dict, dict): + raise OperationLoadException("Export must be a dictionary.") + + if "name" in export_dict: + self._name = export_dict["name"] + if not isinstance(self.name, str): + raise OperationLoadException('"name" in export must exist and be a string.') + else: + raise OperationLoadException('"name" is not defined in export.') + + options_path = os.path.abspath( + os.path.join( + "config", + "edit_plugins", + f"""{''.join(c for c in self._name if c in ValidChrs)}_{ + struct.unpack( + "H", + hashlib.sha1( + self._path.encode('utf-8') + ).digest()[:2] + )[0] + }.config""" # generate a file name that identifiable to the operation but "unique" to the path + ) + ) + + if "operation" in export_dict: + if inspect.isclass(export_dict["operation"]) and issubclass(export_dict["operation"], (wx.Window, wx.Sizer)): + operation_ui: Type[OperationUI] = export_dict.get("operation", None) + if not issubclass(operation_ui, OperationUI): + raise OperationLoadException('"operation" must be a subclass of edit.plugins.OperationUI.') + self._ui = lambda parent, canvas, world: operation_ui(parent, canvas, world, options_path) + + elif callable(export_dict["operation"]): + operation = export_dict.get("operation", None) + if not callable(operation): + raise OperationLoadException('"operation" in export must be callable.') + if operation.__code__.co_argcount != 4: + raise OperationLoadException('"operation" function in export must have 4 inputs.') + options = export_dict.get("options", {}) + if not isinstance(options, dict): + raise OperationLoadException('"operation" in export must be a dictionary if defined.') + self._ui = lambda parent, canvas, world: FixedFunctionUI(parent, canvas, world, options_path, operation, options) + + else: + raise OperationLoadException('"operation" in export must be a callable, or a subclass of wx.Window or wx.Sizer.') + else: + raise OperationLoadException('"operation" is not present in export.') + + @property + def name(self) -> str: + return self._name + + def __call__(self, parent: wx.Window, canvas: "EditCanvas", world: "World") -> OperationUIType: + return self._ui(parent, canvas, world) + + +def _load_module_file(module_path: str): + spec = importlib.util.spec_from_file_location( + os.path.basename(module_path), module_path + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _load_module_directory(module_path): + import sys + + spec = importlib.util.spec_from_file_location( + os.path.basename(os.path.dirname(module_path)), module_path + ) + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod + + +def parse_export(plugin: dict, operations_dict: Dict[str, OperationLoader], path: str): + try: + operations_dict[path] = OperationLoader(plugin, path) + except OperationLoadException as e: + log.error(f"Error loading plugin {path}. {e}") + except Exception as e: + log.error(f"Exception loading plugin {path}. {e}") + + +def _load_operations(operations_: OperationStorageType, path: str): + """load all operations from a specified directory""" + if os.path.isdir(path): + for fpath in glob.iglob(os.path.join(path, "*.py")): + if fpath.endswith("__init__.py"): + continue + + mod = _load_module_file(fpath) + if hasattr(mod, "export"): + plugin = getattr(mod, "export") + parse_export(plugin, operations_, fpath) + elif hasattr(mod, "exports"): + for plugin in getattr(mod, "exports"): + parse_export(plugin, operations_, fpath) + + for dpath in glob.iglob(os.path.join(path, "**", "__init__.py")): + mod = _load_module_directory(dpath) + if hasattr(mod, "export"): + plugin = getattr(mod, "export") + parse_export( + plugin, operations_, os.path.basename(os.path.dirname(dpath)) + ) + elif hasattr(mod, "exports"): + for plugin in getattr(mod, "exports"): + parse_export( + plugin, operations_, os.path.basename(os.path.dirname(dpath)) + ) + + +def _load_operations_group(dir_paths: List[str]): + """Load operations from a list of directory paths""" + operations_: OperationStorageType = {} + for dir_path in dir_paths: + _load_operations(operations_, dir_path) + return operations_ + + +all_operations: OperationStorageType = {} +internal_operations: OperationStorageType = {} +operations: OperationStorageType = {} +export_operations: OperationStorageType = {} +import_operations: OperationStorageType = {} +_meta: Dict[str, OperationStorageType] = { + 'internal_operations': internal_operations, + 'operations': operations, + 'export_operations': export_operations, + 'import_operations': import_operations +} +_public = { + 'operations', + 'export_operations', + 'import_operations' +} + + +def merge_operations(): + """Merge all loaded operations into all_operations""" + all_operations.clear() + for ops in _meta.values(): + all_operations.update(ops) + + +def _reload_operation_name(dir_name): + """reload all operations in a directory name.""" + if dir_name in _public: + os.makedirs(os.path.join("plugins", dir_name), exist_ok=True) + _meta[dir_name].clear() + name_operations = _load_operations_group( + [os.path.join(STOCK_PLUGINS_DIR, dir_name)] + + [os.path.join(CUSTOM_PLUGINS_DIR, dir_name)] * (dir_name in _public) + ) + _meta[dir_name].update(name_operations) + + +def reload_operations(): + """Reload all operations""" + for dir_name in _meta.keys(): + _reload_operation_name(dir_name) + merge_operations() + + +reload_operations() diff --git a/amulet_map_editor/programs/edit/plugins/api/operation_ui.py b/amulet_map_editor/programs/edit/plugins/api/operation_ui.py new file mode 100644 index 00000000..93b70976 --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/api/operation_ui.py @@ -0,0 +1,51 @@ +import pickle +from typing import Any, TYPE_CHECKING, Union +import os +import wx +import weakref + +if TYPE_CHECKING: + from amulet_map_editor.programs.edit.canvas.edit_canvas import EditCanvas + from amulet.api.world import World + +OperationUIType = Union[wx.Window, wx.Sizer, "OperationUI"] + + +class OperationUI: + """The base class that all operations must inherit from.""" + def __init__(self, parent: wx.Window, canvas: "EditCanvas", world: "World", options_path: str): + self._parent = weakref.ref(parent) + self._canvas = weakref.ref(canvas) + self._world = weakref.ref(world) + self._options_path = options_path + + @property + def parent(self) -> wx.Window: + return self._parent() + + @property + def canvas(self) -> "EditCanvas": + return self._canvas() + + @property + def world(self) -> "World": + return self._world() + + def unload(self): + """Unbind any events that have been set up and make safe to destroy the UI. + The UI will be destroyed from externally.""" + raise NotImplementedError + + def _load_options(self, default=None) -> Any: + """Load previously saved options from disk or return the default options.""" + try: + with open(self._options_path, "rb") as f: + return pickle.load(f) + except: + return default + + def _save_options(self, options: Any): + """Save the given options to disk so that they persist in the next session.""" + os.makedirs(os.path.dirname(self._options_path), exist_ok=True) + with open(self._options_path, "wb") as f: + return pickle.dump(options, f) diff --git a/amulet_map_editor/programs/edit/plugins/api/simple_operation_panel.py b/amulet_map_editor/programs/edit/plugins/api/simple_operation_panel.py new file mode 100644 index 00000000..036530f6 --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/api/simple_operation_panel.py @@ -0,0 +1,44 @@ +import wx +from typing import TYPE_CHECKING + +from amulet.api.selection import SelectionGroup +from amulet.api.data_types import Dimension, OperationReturnType + +from .operation_ui import OperationUI + +if TYPE_CHECKING: + from amulet.api.world import World + from amulet_map_editor.programs.edit.canvas.edit_canvas import EditCanvas + + +class SimpleOperationPanel(wx.Panel, OperationUI): + def __init__( + self, + parent: wx.Window, + canvas: "EditCanvas", + world: "World", + options_path: str + ): + wx.Panel.__init__(self, parent) + OperationUI.__init__(self, parent, canvas, world, options_path) + + self._sizer = wx.BoxSizer(wx.VERTICAL) + self.SetSizer(self._sizer) + + def _add_run_button(self, label="Run Operation"): + self._run_button = wx.Button(self, label=label) + self._run_button.Bind(wx.EVT_BUTTON, self._run_operation) + self._sizer.Add(self._run_button, 0, wx.ALL | wx.ALIGN_CENTRE_HORIZONTAL, 5) + self.Layout() + + def _run_operation(self, _): + self.canvas.run_operation( + lambda: self._operation( + self.world, + self.canvas.dimension, + self.canvas.selection_group + ) + ) + + def _operation(self, world: "World", dimension: Dimension, selection: SelectionGroup) -> OperationReturnType: + raise NotImplementedError diff --git a/amulet_map_editor/programs/edit/plugins/examples/1_fixed_function_pipeline.py b/amulet_map_editor/programs/edit/plugins/examples/1_fixed_function_pipeline.py new file mode 100644 index 00000000..d6785573 --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/examples/1_fixed_function_pipeline.py @@ -0,0 +1,23 @@ +# Fixed function example plugin +# The fixed function operation pipeline works in much the same way as MCEdit Unified filters with some modifications +# You define a function and it will appear in the UI for you to run + + +from amulet.api.selection import SelectionGroup +from amulet.api.world import World +from amulet.api.data_types import Dimension + + +# for those that are new to python 3 the thing after the colon is the object type that the variable should be +def operation(world: World, dimension: Dimension, selection: SelectionGroup, options: dict): + # world is the object that contains all the data related to the world + # dimension in a string used to identify the currently loaded dimension. It can be used to access the right dimension from the world + # selection is an object describing the selections made by the user. It is possible there may not be any boxes selected + # options will be explored in further examples + pass + + +export = { # This is what the program will actually look for. It describes how the operation will work + "name": "Fixed Function Pipeline Example 1", # the name of the plugin + "operation": operation, # the actual function to call when running the plugin +} diff --git a/amulet_map_editor/programs/edit/plugins/examples/2_fixed_function_pipeline.py b/amulet_map_editor/programs/edit/plugins/examples/2_fixed_function_pipeline.py new file mode 100644 index 00000000..faf275ee --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/examples/2_fixed_function_pipeline.py @@ -0,0 +1,31 @@ +# Fixed function example plugin 2 +# see example 1 before looking at this example +# Notes about the operation + + +from amulet.api.selection import SelectionGroup +from amulet.api.world import World +from amulet.api.data_types import Dimension + + +# Notes about the operation +# The operation is allowed to yield floats in the range 0 to 1. +# This is used to update the loading bar in the UI. Without this the UI may appear to be not responding +# It can optionally also yield a float and a string. The float is the same as the above and the string is used to display in the loading bar +# The operation is allowed to return a value however nothing will be done with it + +def operation(world: World, dimension: Dimension, selection: SelectionGroup, options: dict): + for i in range(10): + # do some logic + yield (i+1)/10 + + for i in range(10): + # do some logic + yield (i+1)/10, f"Step {i} of 10" + return 'hello' # This will not actually do anything but is allowed + + +export = { + "name": "Fixed Function Pipeline Example 2", + "operation": operation, +} diff --git a/amulet_map_editor/programs/edit/plugins/examples/3_fixed_function_pipeline.py b/amulet_map_editor/programs/edit/plugins/examples/3_fixed_function_pipeline.py new file mode 100644 index 00000000..c0cd5474 --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/examples/3_fixed_function_pipeline.py @@ -0,0 +1,58 @@ +# Fixed function example plugin 3 +# see example 2 before looking at this example +# This example shows how to use options with the fixed function pipeline + + +from amulet.api.selection import SelectionGroup +from amulet.api.world import World +from amulet.api.data_types import Dimension + + +operation_options = { # options is a dictionary where the key is the description shown to the user and the value describes how to build the UI + "Text Label": ["label"], # This will just be a label + + # bool examples https://wxpython.org/Phoenix/docs/html/wx.CheckBox.html + "Bool input default": ["bool"], # This will be a check box that takes the default value (unchecked) + "Bool input False": ["bool", False], # This will be a check box that starts unchecked + "Bool input True": ["bool", True], # This will be a check box that starts checked + + # int examples https://wxpython.org/Phoenix/docs/html/wx.SpinCtrl.html + "Int input default": ["int"], # This will be an integer input with starting value 0 + "Int input 10": ["int", 10], # This will be an integer input with starting value 10 (or whatever you put in the second slot) + "Int input 10 bounded": ["int", 10, 0, 20], # same as above but must be between the third and fourth values + + # float examples https://wxpython.org/Phoenix/docs/html/wx.SpinCtrlDouble.html + "Float input default": ["float"], # This will be a float input with starting value 0.0 + "Float input 10": ["float", 10], # This will be a float input with starting value 10.0 (or whatever you put in the second slot) + "Float input 10 bounded": ["float", 10, 0, 20], # same as above but must be between the third and fourth values + + # string input examples https://wxpython.org/Phoenix/docs/html/wx.TextCtrl.html + "String input empty": ["str"], # This will be a text input with an empty starting value + "String input empty2": ["str", ""], # Same as the above + "String input hello": ["str", "hello"], # Text entry with starting value "hello" + + # string choice examples https://wxpython.org/Phoenix/docs/html/wx.Choice.html + "Text choice": ["str_choice", "choice 1", "choice 2", "choice 3"], + + # OS examples + "File Open picker": ["file_open"], # UI to pick an existing file + "File Save picker": ["file_save"], # UI to pick a file to save to + "Folder picker": ["directory"] # UI to pick a directory +} + + +def operation(world: World, dimension: Dimension, selection: SelectionGroup, options: dict): + # When the user presses the run button this function will be run as normal but + # since the "options" key was defined in export this function will get another + # input in the form of a dictionary where the keys are the same as you defined + # them in the options dictionary above and the values are what the user picked + # in the UI (bool, int, float, str) + # If "options" is not defined in export this will just be an empty dictionary + pass + + +export = { + "name": "Fixed Function Pipeline Example 3", + "operation": operation, + "options": operation_options # The options you defined above should be added here to show in the UI +} diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/__init__.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/export_operations/__init__.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/export_operations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/export_operations/construction.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/export_operations/construction.py new file mode 100644 index 00000000..c14a094c --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/stock_plugins/export_operations/construction.py @@ -0,0 +1,81 @@ +from typing import TYPE_CHECKING +import wx + +from amulet.api.selection import SelectionGroup +from amulet.api.errors import ChunkLoadError +from amulet.api.data_types import Dimension, OperationReturnType +from amulet.structure_interface.construction import ConstructionFormatWrapper + +from amulet_map_editor.amulet_wx.ui.select_block import VersionSelect +from amulet_map_editor.programs.edit.plugins.api.simple_operation_panel import SimpleOperationPanel +from amulet_map_editor.programs.edit.plugins.api.errors import OperationError + + +if TYPE_CHECKING: + from amulet.api.world import World + from amulet_map_editor.programs.edit.canvas.edit_canvas import EditCanvas + + +class ExportConstruction(SimpleOperationPanel): + def __init__( + self, + parent: wx.Window, + canvas: "EditCanvas", + world: "World", + options_path: str + ): + SimpleOperationPanel.__init__(self, parent, canvas, world, options_path) + + options = self._load_options({}) + + self._file_picker = wx.FilePickerCtrl( + self, + path=options.get('path', ''), + wildcard="Construction file (*.construction)|*.construction", + style=wx.FLP_USE_TEXTCTRL | wx.FLP_SAVE | wx.FLP_OVERWRITE_PROMPT + ) + self._sizer.Add(self._file_picker, 0, wx.ALL | wx.CENTER, 5) + self._version_define = VersionSelect( + self, + world.translation_manager, + options.get("platform", None) or world.world_wrapper.platform, + allow_universal=False + ) + self._sizer.Add(self._version_define, 0, wx.CENTRE, 5) + self._add_run_button("Export") + self.Layout() + + def unload(self): + self._save_options({ + "path": self._file_picker.GetPath(), + "platform": self._version_define.platform, + "version": self._version_define.version + }) + + def _operation(self, world: "World", dimension: Dimension, selection: SelectionGroup) -> OperationReturnType: + path = self._file_picker.GetPath() + platform = self._version_define.platform + version = self._version_define.version + if isinstance(path, str) and path.endswith('.construction') and platform and version: + wrapper = ConstructionFormatWrapper(path, 'w') + wrapper.platform = platform + wrapper.version = version + wrapper.selection = selection + wrapper.translation_manager = world.translation_manager + wrapper.open() + for cx, cz in selection.chunk_locations(): + try: + chunk = world.get_chunk(cx, cz, dimension) + wrapper.commit_chunk(chunk, world.palette) + except ChunkLoadError: + continue + + wrapper.close() + else: + raise OperationError('Please specify a save location and version in the options before running.') + + +export = { + "name": "\tExport Construction", # the name of the plugin + "operation": ExportConstruction, # the UI class to display +} diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/export_operations/mcstructure.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/export_operations/mcstructure.py new file mode 100644 index 00000000..04448e59 --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/stock_plugins/export_operations/mcstructure.py @@ -0,0 +1,85 @@ +from typing import TYPE_CHECKING +import wx + +from amulet.api.selection import SelectionGroup +from amulet.api.errors import ChunkLoadError +from amulet.api.data_types import Dimension, OperationReturnType +from amulet.structure_interface.mcstructure import MCStructureFormatWrapper + +from amulet_map_editor.amulet_wx.ui.select_block import VersionSelect +from amulet_map_editor.programs.edit.plugins.api.simple_operation_panel import SimpleOperationPanel +from amulet_map_editor.programs.edit.plugins.api.errors import OperationError + + +if TYPE_CHECKING: + from amulet.api.world import World + from amulet_map_editor.programs.edit.canvas.edit_canvas import EditCanvas + + +class ExportMCStructure(SimpleOperationPanel): + def __init__( + self, + parent: wx.Window, + canvas: "EditCanvas", + world: "World", + options_path: str + ): + SimpleOperationPanel.__init__(self, parent, canvas, world, options_path) + + options = self._load_options({}) + + self._file_picker = wx.FilePickerCtrl( + self, + path=options.get('path', ''), + wildcard="mcstructure file (*.mcstructure)|*.mcstructure", + style=wx.FLP_USE_TEXTCTRL | wx.FLP_SAVE | wx.FLP_OVERWRITE_PROMPT + ) + self._sizer.Add(self._file_picker, 0, wx.ALL | wx.CENTER, 5) + self._version_define = VersionSelect( + self, + world.translation_manager, + options.get("platform", None) or world.world_wrapper.platform, + allowed_platforms=("bedrock", ), + allow_numerical=False + ) + self._sizer.Add(self._version_define, 0, wx.CENTRE, 5) + self._add_run_button("Export") + self.Layout() + + def unload(self): + self._save_options({ + "path": self._file_picker.GetPath(), + "version": self._version_define.version + }) + + def _operation(self, world: "World", dimension: Dimension, selection: SelectionGroup) -> OperationReturnType: + if len(selection.selection_boxes) == 0: + raise OperationError("No selection was given to export.") + elif len(selection.selection_boxes) != 1: + raise OperationError("The mcstructure format only supports a single selection box.") + + path = self._file_picker.GetPath() + version = self._version_define.version + + if isinstance(path, str) and path.endswith('.mcstructure') and version: + wrapper = MCStructureFormatWrapper(path, 'w') + wrapper.selection = selection + wrapper.version = version + wrapper.translation_manager = world.translation_manager + wrapper.open() + for cx, cz in wrapper.selection.chunk_locations(): + try: + chunk = world.get_chunk(cx, cz, dimension) + wrapper.commit_chunk(chunk, world.palette) + except ChunkLoadError: + continue + + wrapper.close() + else: + raise OperationError('Please specify a save location and version in the options before running.') + + +export = { + "name": "Export Bedrock .mcstructure", # the name of the plugin + "operation": ExportMCStructure, # the UI class to display +} diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/export_operations/schematic.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/export_operations/schematic.py new file mode 100644 index 00000000..a2c43431 --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/stock_plugins/export_operations/schematic.py @@ -0,0 +1,102 @@ +from typing import TYPE_CHECKING +import wx + +from amulet.api.selection import SelectionGroup +from amulet.api.errors import ChunkLoadError +from amulet.api.data_types import Dimension, OperationReturnType +from amulet.structure_interface.schematic import SchematicFormatWrapper + +from amulet_map_editor.amulet_wx.ui.select_block import PlatformSelect +from amulet_map_editor.programs.edit.plugins.api.simple_operation_panel import SimpleOperationPanel +from amulet_map_editor.programs.edit.plugins.api.errors import OperationError + + +if TYPE_CHECKING: + from amulet.api.world import World + from amulet_map_editor.programs.edit.canvas.edit_canvas import EditCanvas + + +WarningMsg = """The Schematic format is a +legacy format that can only +save data in the numerical +format. Anything that was +added to the game in version +1.13 or after will not be +saved in the schematic file. + +We suggest using the Construction +file format instead.""" + + +class ExportSchematic(SimpleOperationPanel): + def __init__( + self, + parent: wx.Window, + canvas: "EditCanvas", + world: "World", + options_path: str + ): + SimpleOperationPanel.__init__(self, parent, canvas, world, options_path) + + options = self._load_options({}) + + self._file_picker = wx.FilePickerCtrl( + self, + path=options.get('path', ''), + wildcard="Schematic file (*.schematic)|*.schematic", + style=wx.FLP_USE_TEXTCTRL | wx.FLP_SAVE | wx.FLP_OVERWRITE_PROMPT + ) + self._sizer.Add(self._file_picker, 0, wx.ALL | wx.CENTER, 5) + self._platform_define = PlatformSelect( + self, + world.translation_manager, + options.get("platform", None) or world.world_wrapper.platform, + allow_universal=False + ) + self._sizer.Add(self._platform_define, 0, wx.CENTRE, 5) + self._sizer.Add( + wx.StaticText( + self, + label=WarningMsg, + style=wx.ALIGN_CENTRE_HORIZONTAL + ), 0, wx.ALL | wx.CENTER, 5 + ) + self._add_run_button("Export") + self.Layout() + + def unload(self): + self._save_options({ + "path": self._file_picker.GetPath(), + "platform": self._platform_define.platform, + }) + + def _operation(self, world: "World", dimension: Dimension, selection: SelectionGroup) -> OperationReturnType: + if len(selection.selection_boxes) == 0: + raise OperationError("No selection was given to export.") + elif len(selection.selection_boxes) != 1: + raise OperationError("The schematic format only supports a single selection box.") + + path = self._file_picker.GetPath() + platform = self._platform_define.platform + if isinstance(path, str) and path.endswith('.schematic') and platform: + wrapper = SchematicFormatWrapper(path, 'w') + wrapper.platform = platform + wrapper.selection = selection + wrapper.translation_manager = world.translation_manager + wrapper.open() + for cx, cz in wrapper.selection.chunk_locations(): + try: + chunk = world.get_chunk(cx, cz, dimension) + wrapper.commit_chunk(chunk, world.palette) + except ChunkLoadError: + continue + + wrapper.close() + else: + raise OperationError('Please specify a save location and platform in the options before running.') + + +export = { + "name": "Export Schematic (legacy)", # the name of the plugin + "operation": ExportSchematic, # the UI class to display +} diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/import_operations/__init__.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/import_operations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/import_operations/construction.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/import_operations/construction.py new file mode 100644 index 00000000..4851b967 --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/stock_plugins/import_operations/construction.py @@ -0,0 +1,79 @@ +import os +import wx +from typing import TYPE_CHECKING + + +from amulet.api.block import BlockManager +from amulet.api.structure import Structure +from amulet.api.selection import SelectionGroup +from amulet.api.errors import ChunkLoadError +from amulet.api.data_types import Dimension +from amulet.structure_interface.construction import ConstructionFormatWrapper + +from amulet_map_editor.programs.edit.plugins.api.simple_operation_panel import SimpleOperationPanel +from amulet_map_editor.programs.edit.plugins.api.errors import OperationError + +if TYPE_CHECKING: + from amulet.api.world import World + from amulet_map_editor.programs.edit.canvas.edit_canvas import EditCanvas + + +class ImportConstruction(SimpleOperationPanel): + def __init__( + self, + parent: wx.Window, + canvas: "EditCanvas", + world: "World", + options_path: str + ): + SimpleOperationPanel.__init__(self, parent, canvas, world, options_path) + + options = self._load_options({}) + + self._file_picker = wx.FilePickerCtrl( + self, + path=options.get('path', ''), + wildcard="Construction file (*.construction)|*.construction", + style=wx.FLP_USE_TEXTCTRL | wx.FLP_OPEN + ) + self._sizer.Add(self._file_picker, 0, wx.ALL | wx.CENTER, 5) + self._add_run_button("Import") + self.Layout() + + def unload(self): + self._save_options({ + "path": self._file_picker.GetPath() + }) + + def _operation(self, world: "World", dimension: Dimension, selection: SelectionGroup): + path = self._file_picker.GetPath() + if isinstance(path, str) and path.endswith('.construction') and os.path.isfile(path): + wrapper = ConstructionFormatWrapper(path, 'r') + wrapper.translation_manager = world.translation_manager + wrapper.open() + selection = wrapper.selection + + global_palette = BlockManager() + chunks = {} + for (cx, cz) in wrapper.all_chunk_coords(): + try: + chunks[(cx, cz)] = wrapper.load_chunk(cx, cz, global_palette) + except ChunkLoadError: + pass + + wrapper.close() + self.canvas.paste( + Structure( + chunks, + global_palette, + selection + ) + ) + else: + raise OperationError('Please specify a construction file in the options before running.') + + +export = { + "name": "\tImport Construction", # the name of the plugin + "operation": ImportConstruction, # the UI class to display +} diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/import_operations/mcstructure.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/import_operations/mcstructure.py new file mode 100644 index 00000000..789da54c --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/stock_plugins/import_operations/mcstructure.py @@ -0,0 +1,80 @@ +import wx +import os +from typing import TYPE_CHECKING + + +from amulet.api.block import BlockManager +from amulet.api.structure import Structure +from amulet.api.selection import SelectionGroup +from amulet.api.errors import ChunkLoadError +from amulet.api.data_types import Dimension +from amulet.structure_interface.mcstructure import MCStructureFormatWrapper + +from amulet_map_editor.programs.edit.plugins.api.simple_operation_panel import SimpleOperationPanel +from amulet_map_editor.programs.edit.plugins.api.errors import OperationError + +if TYPE_CHECKING: + from amulet.api.world import World + from amulet_map_editor.programs.edit.canvas.edit_canvas import EditCanvas + + +class ImportMCStructure(SimpleOperationPanel): + def __init__( + self, + parent: wx.Window, + canvas: "EditCanvas", + world: "World", + options_path: str + ): + SimpleOperationPanel.__init__(self, parent, canvas, world, options_path) + + options = self._load_options({}) + + self._file_picker = wx.FilePickerCtrl( + self, + path=options.get('path', ''), + wildcard="Bedrock mcstructure file (*.mcstructure)|*.mcstructure", + style=wx.FLP_USE_TEXTCTRL | wx.FLP_OPEN + ) + self._sizer.Add(self._file_picker, 0, wx.ALL | wx.CENTER, 5) + self._add_run_button("Import") + self.Layout() + + def unload(self): + self._save_options({ + "path": self._file_picker.GetPath() + }) + + def _operation(self, world: "World", dimension: Dimension, selection: SelectionGroup): + path = self._file_picker.GetPath() + + if isinstance(path, str) and path.endswith('.mcstructure') and os.path.isfile(path): + wrapper = MCStructureFormatWrapper(path, 'r') + wrapper.translation_manager = world.translation_manager + wrapper.open() + selection = wrapper.selection + + global_palette = BlockManager() + chunks = {} + for (cx, cz) in wrapper.all_chunk_coords(): + try: + chunks[(cx, cz)] = wrapper.load_chunk(cx, cz, global_palette) + except ChunkLoadError: + pass + + wrapper.close() + self.canvas.paste( + Structure( + chunks, + global_palette, + selection + ) + ) + else: + raise OperationError('Please specify a mcstructure file in the options before running.') + + +export = { + "name": "Import Bedrock .mcstructure", # the name of the plugin + "operation": ImportMCStructure, # the UI class to display +} diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/import_operations/schematic.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/import_operations/schematic.py new file mode 100644 index 00000000..b87e5c6b --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/stock_plugins/import_operations/schematic.py @@ -0,0 +1,79 @@ +import wx +import os +from typing import TYPE_CHECKING + + +from amulet.api.block import BlockManager +from amulet.api.structure import Structure +from amulet.api.selection import SelectionGroup +from amulet.api.errors import ChunkLoadError +from amulet.api.data_types import Dimension +from amulet.structure_interface.schematic import SchematicFormatWrapper + +from amulet_map_editor.programs.edit.plugins.api.simple_operation_panel import SimpleOperationPanel +from amulet_map_editor.programs.edit.plugins.api.errors import OperationError + +if TYPE_CHECKING: + from amulet.api.world import World + from amulet_map_editor.programs.edit.canvas.edit_canvas import EditCanvas + + +class ImportSchematic(SimpleOperationPanel): + def __init__( + self, + parent: wx.Window, + canvas: "EditCanvas", + world: "World", + options_path: str + ): + SimpleOperationPanel.__init__(self, parent, canvas, world, options_path) + + options = self._load_options({}) + + self._file_picker = wx.FilePickerCtrl( + self, + path=options.get('path', ''), + wildcard="Schematic file (*.schematic)|*.schematic", + style=wx.FLP_USE_TEXTCTRL | wx.FLP_OPEN + ) + self._sizer.Add(self._file_picker, 0, wx.ALL | wx.CENTER, 5) + self._add_run_button("Import") + self.Layout() + + def unload(self): + self._save_options({ + "path": self._file_picker.GetPath() + }) + + def _operation(self, world: "World", dimension: Dimension, selection: SelectionGroup): + path = self._file_picker.GetPath() + if isinstance(path, str) and path.endswith('.schematic') and os.path.isfile(path): + wrapper = SchematicFormatWrapper(path, 'r') + wrapper.translation_manager = world.translation_manager + wrapper.open() + selection = wrapper.selection + + global_palette = BlockManager() + chunks = {} + for (cx, cz) in wrapper.all_chunk_coords(): + try: + chunks[(cx, cz)] = wrapper.load_chunk(cx, cz, global_palette) + except ChunkLoadError: + pass + + wrapper.close() + self.canvas.paste( + Structure( + chunks, + global_palette, + selection + ) + ) + else: + raise OperationError('Please specify a schematic file in the options before running.') + + +export = { + "name": "Import Schematic (legacy)", # the name of the plugin + "operation": ImportSchematic, # the UI class to display +} diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/internal_operations/__init__.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/internal_operations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/internal_operations/copy.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/internal_operations/copy.py new file mode 100644 index 00000000..cce89e27 --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/stock_plugins/internal_operations/copy.py @@ -0,0 +1,21 @@ +from typing import TYPE_CHECKING + +from amulet.api.structure import structure_cache, Structure +from amulet.api.data_types import Dimension +from amulet.api.selection import SelectionGroup +from amulet_map_editor.programs.edit.plugins.api.errors import OperationSilentAbort + +if TYPE_CHECKING: + from amulet.api.world import World + + +def copy( + world: "World", + dimension: Dimension, + selection: SelectionGroup +): + structure = Structure.from_world( + world, selection, dimension + ) + structure_cache.add_structure(structure) + raise OperationSilentAbort diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/internal_operations/cut.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/internal_operations/cut.py new file mode 100644 index 00000000..f62603ba --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/stock_plugins/internal_operations/cut.py @@ -0,0 +1,25 @@ +from typing import TYPE_CHECKING + +from amulet.api.structure import structure_cache, Structure +from amulet.api.data_types import Dimension, OperationReturnType +from amulet.api.selection import SelectionGroup +from amulet_map_editor.programs.edit.plugins.stock_plugins.internal_operations.delete import delete + +if TYPE_CHECKING: + from amulet.api.world import World + + +def cut( + world: "World", + dimension: Dimension, + selection: SelectionGroup +) -> OperationReturnType: + structure = Structure.from_world( + world, selection, dimension + ) + structure_cache.add_structure(structure) + yield from delete( + world, + dimension, + selection, + ) diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/internal_operations/delete.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/internal_operations/delete.py new file mode 100644 index 00000000..39e18388 --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/stock_plugins/internal_operations/delete.py @@ -0,0 +1,26 @@ +from typing import TYPE_CHECKING + +from amulet.operations.fill import fill +from amulet.api.selection import SelectionGroup +from amulet.api.block import Block +from amulet.api.data_types import Dimension, OperationReturnType + +if TYPE_CHECKING: + from amulet.api.world import World + + +def delete( + world: "World", + dimension: Dimension, + selection: SelectionGroup +) -> OperationReturnType: + yield from fill( + world, + dimension, + selection, + world.translation_manager.get_version( + 'java', (1, 15, 2) + ).block.to_universal( + Block("minecraft", "air") + )[0] + ) diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/operations/__init__.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/operations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/operations/clone.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/operations/clone.py new file mode 100644 index 00000000..4cc72fd8 --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/stock_plugins/operations/clone.py @@ -0,0 +1,42 @@ +from typing import TYPE_CHECKING +import wx + +from amulet.api.structure import Structure +from amulet.api.selection import SelectionGroup +from amulet.api.data_types import Dimension, OperationReturnType + +from amulet_map_editor.programs.edit.plugins.api.simple_operation_panel import SimpleOperationPanel +from amulet_map_editor.programs.edit.plugins.api.errors import OperationSilentAbort + +if TYPE_CHECKING: + from amulet.api.world import World + from amulet_map_editor.programs.edit.canvas.edit_canvas import EditCanvas + + +class Clone(SimpleOperationPanel): + def __init__( + self, + parent: wx.Window, + canvas: "EditCanvas", + world: "World", + options_path: str + ): + SimpleOperationPanel.__init__(self, parent, canvas, world, options_path) + self._add_run_button() + self.Layout() + + def _operation(self, world: "World", dimension: Dimension, selection: SelectionGroup) -> OperationReturnType: + structure = Structure.from_world( + world, selection, dimension + ) + self.canvas.paste(structure) + raise OperationSilentAbort + + def unload(self): + pass + + +export = { + "name": "Clone", # the name of the plugin + "operation": Clone # the actual function to call when running the plugin +} diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/operations/delete_chunk.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/operations/delete_chunk.py new file mode 100644 index 00000000..b339ae03 --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/stock_plugins/operations/delete_chunk.py @@ -0,0 +1,11 @@ +from amulet.operations.delete_chunk import delete_chunk + + +def delete_chunk_wrapper(world, dimension, selection, _): + return delete_chunk(world, dimension, selection) + + +export = { + "name": "Delete Chunks", # the name of the plugin + "operation": delete_chunk_wrapper # the actual function to call when running the plugin +} diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/operations/fill.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/operations/fill.py new file mode 100644 index 00000000..834a9a98 --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/stock_plugins/operations/fill.py @@ -0,0 +1,73 @@ +from typing import TYPE_CHECKING +import wx + +from amulet.operations.fill import fill +from amulet_map_editor.amulet_wx.ui.select_block import BlockDefine +from amulet_map_editor.programs.edit.plugins import OperationUI + +if TYPE_CHECKING: + from amulet.api.world import World + from amulet_map_editor.programs.edit.canvas.edit_canvas import EditCanvas + + +class Fill(wx.Panel, OperationUI): + def __init__( + self, + parent: wx.Window, + canvas: "EditCanvas", + world: "World", + options_path: str + ): + wx.Panel.__init__(self, parent) + OperationUI.__init__(self, parent, canvas, world, options_path) + + self._sizer = wx.BoxSizer(wx.VERTICAL) + self.SetSizer(self._sizer) + + options = self._load_options({}) + + self._block_define = BlockDefine( + self, + world.world_wrapper.translation_manager, + *(options.get("fill_block_options", []) or [world.world_wrapper.platform]), + style=wx.BORDER_SIMPLE, + properties_style=wx.BORDER_SIMPLE + ) + self._sizer.Add(self._block_define, 0, wx.ALL | wx.ALIGN_CENTRE_HORIZONTAL, 5) + + self._run_button = wx.Button(self, label="Run Operation") + self._run_button.Bind(wx.EVT_BUTTON, self._run_operation) + self._sizer.Add(self._run_button, 0, wx.ALL | wx.ALIGN_CENTRE_HORIZONTAL, 5) + + self.Layout() + + def _get_fill_block(self): + return self.world.world_wrapper.translation_manager.get_version( + self._block_define.platform, + self._block_define.version + ).block.to_universal( + self._block_define.block, + force_blockstate=self._block_define.force_blockstate + )[0] + + def unload(self): + self._save_options({ + "fill_block": self._get_fill_block(), + "fill_block_options": self._block_define.options + }) + + def _run_operation(self, _): + self.canvas.run_operation( + lambda: fill( + self.world, + self.canvas.dimension, + self.canvas.selection_group, + self._get_fill_block() + ) + ) + + +export = { + "name": "Fill", # the name of the plugin + "operation": Fill, # the actual function to call when running the plugin +} diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/operations/replace.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/operations/replace.py new file mode 100644 index 00000000..cdce0def --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/stock_plugins/operations/replace.py @@ -0,0 +1,125 @@ +from typing import TYPE_CHECKING +import wx +import numpy + +from amulet.api.block import Block +from amulet_map_editor.amulet_wx.ui.select_block import BlockDefine +from amulet_map_editor.programs.edit.plugins import OperationUI +from amulet_map_editor.amulet_wx.ui.simple import SimpleScrollablePanel + +if TYPE_CHECKING: + from amulet.api.world import World + from amulet_map_editor.programs.edit.canvas.edit_canvas import EditCanvas + + +class Replace(SimpleScrollablePanel, OperationUI): + def __init__( + self, + parent: wx.Window, + canvas: "EditCanvas", + world: "World", + options_path: str + ): + SimpleScrollablePanel.__init__(self, parent) + OperationUI.__init__(self, parent, canvas, world, options_path) + + options = self._load_options({}) + + self._original_block = BlockDefine( + self, + world.world_wrapper.translation_manager, + *(options.get("original_block_options", []) or [world.world_wrapper.platform]), + wildcard=True, + style=wx.BORDER_SIMPLE, + properties_style=wx.BORDER_SIMPLE + ) + self._sizer.Add(self._original_block, 0, wx.ALL | wx.ALIGN_CENTRE_HORIZONTAL, 5) + self._replacement_block = BlockDefine( + self, + world.world_wrapper.translation_manager, + *(options.get("replacement_block_options", []) or [world.world_wrapper.platform]), + style=wx.BORDER_SIMPLE, + properties_style=wx.BORDER_SIMPLE + ) + self._sizer.Add(self._replacement_block, 0, wx.ALL | wx.ALIGN_CENTRE_HORIZONTAL, 5) + + self._run_button = wx.Button(self, label="Run Operation") + self._run_button.Bind(wx.EVT_BUTTON, self._run_operation) + self._sizer.Add(self._run_button, 0, wx.ALL | wx.ALIGN_CENTRE_HORIZONTAL, 5) + + self.Layout() + + def _get_replacement_block(self) -> Block: + return self.world.translation_manager.get_version( + self._replacement_block.platform, + self._replacement_block.version + ).block.to_universal( + self._replacement_block.block, + force_blockstate=self._replacement_block.force_blockstate + )[0] + + def unload(self): + self._save_options({ + "original_block_options": self._original_block.options, + "replacement_block": self._get_replacement_block(), + "replacement_block_options": self._replacement_block.options, + }) + + def _run_operation(self, _): + self.canvas.run_operation( + lambda: self._replace() + ) + + def _replace(self): + world = self.world + selection = self.canvas.selection_group + dimension = self.canvas.dimension + + original_platform, original_version, original_blockstate, original_namespace, original_base_name, original_properties = self._original_block.options + replacement_block = self._get_replacement_block() + + replacement_block_id = world.palette.get_add_block(replacement_block) + + original_block_matches = [] + universal_block_count = 0 + + iter_count = len(list(world.get_chunk_slices(selection, dimension))) + count = 0 + + for chunk, slices, _ in world.get_chunk_slices(selection, dimension): + if universal_block_count < len(world.palette): + for universal_block_id in range(universal_block_count, len(world.palette)): + version_block = world.translation_manager.get_version( + original_platform, + original_version + ).block.from_universal( + world.palette[universal_block_id], + force_blockstate=original_blockstate + )[0] + if version_block.namespace == original_namespace and \ + version_block.base_name == original_base_name \ + and all(original_properties.get(prop) in ['*', val.to_snbt()] for prop, val in version_block.properties.items()): + original_block_matches.append(universal_block_id) + + universal_block_count = len(world.palette) + blocks = chunk.blocks[slices] + blocks[numpy.isin(blocks, original_block_matches)] = replacement_block_id + chunk.blocks[slices] = blocks + chunk.changed = True + + count += 1 + yield 100 * count / iter_count + + def DoGetBestClientSize(self): + sizer = self.GetSizer() + if sizer is None: + return -1, -1 + else: + sx, sy = self.GetSizer().CalcMin() + return sx + wx.SystemSettings.GetMetric(wx.SYS_VSCROLL_X), sy + wx.SystemSettings.GetMetric(wx.SYS_HSCROLL_Y) + + +export = { + "name": "Replace", # the name of the plugin + "operation": Replace, # the actual function to call when running the plugin +} diff --git a/amulet_map_editor/programs/edit/plugins/stock_plugins/operations/waterlog.py b/amulet_map_editor/programs/edit/plugins/stock_plugins/operations/waterlog.py new file mode 100644 index 00000000..3a24c1e8 --- /dev/null +++ b/amulet_map_editor/programs/edit/plugins/stock_plugins/operations/waterlog.py @@ -0,0 +1,94 @@ +import numpy +from typing import TYPE_CHECKING +import wx + +from amulet_map_editor.amulet_wx.ui.select_block import BlockDefine +from amulet_map_editor.programs.edit.plugins import OperationUI + +if TYPE_CHECKING: + from amulet.api.world import World + from amulet_map_editor.programs.edit.canvas.edit_canvas import EditCanvas + + +class Waterlog(wx.Panel, OperationUI): + def __init__( + self, + parent: wx.Window, + canvas: "EditCanvas", + world: "World", + options_path: str + ): + wx.Panel.__init__(self, parent) + OperationUI.__init__(self, parent, canvas, world, options_path) + + self._sizer = wx.BoxSizer(wx.VERTICAL) + self.SetSizer(self._sizer) + + options = self._load_options({}) + + self._block_define = BlockDefine( + self, + world.world_wrapper.translation_manager, + *(options.get("fill_block_options", []) or [world.world_wrapper.platform]), + style=wx.BORDER_SIMPLE, + properties_style=wx.BORDER_SIMPLE + ) + self._sizer.Add(self._block_define, 0, wx.ALL | wx.ALIGN_CENTRE_HORIZONTAL, 5) + + self._run_button = wx.Button(self, label="Run Operation") + self._run_button.Bind(wx.EVT_BUTTON, self._run_operation) + self._sizer.Add(self._run_button, 0, wx.ALL | wx.ALIGN_CENTRE_HORIZONTAL, 5) + + self.Layout() + + def _get_fill_block(self): + return self.world.world_wrapper.translation_manager.get_version( + self._block_define.platform, + self._block_define.version + ).block.to_universal( + self._block_define.block, + force_blockstate=self._block_define.force_blockstate + )[0] + + def unload(self): + self._save_options({ + "fill_block": self._get_fill_block(), + "fill_block_options": self._block_define.options + }) + + def _run_operation(self, _): + self.canvas.run_operation( + lambda: self._waterlog() + ) + + def _waterlog(self): + waterlog_block = self._get_fill_block() + world = self.world + selection = self.canvas.selection_group + dimension = self.canvas.dimension + iter_count = len(list(world.get_chunk_slices(selection, dimension, True))) + count = 0 + for chunk, slices, _ in world.get_chunk_slices(selection, dimension, True): + original_blocks = chunk.blocks[slices] + palette, blocks = numpy.unique(original_blocks, return_inverse=True) + blocks = blocks.reshape(original_blocks.shape) + lut = numpy.array( + [ + world.palette.get_add_block( + world.palette[block_id] + waterlog_block # get the Block object for that id and add the user specified block + ) # register the new block / get the numerical id if it was already registered + for block_id in palette + ] # add the new id to the palette + ) + + chunk.blocks[slices] = lut[blocks] + + chunk.changed = True + count += 1 + yield 100 * count / iter_count + + +export = { + "name": "Waterlog", # the name of the plugin + "operation": Waterlog, # the actual function to call when running the plugin +} diff --git a/amulet_map_editor/programs/edit/ui/file.py b/amulet_map_editor/programs/edit/ui/file.py deleted file mode 100644 index 04ae560e..00000000 --- a/amulet_map_editor/programs/edit/ui/file.py +++ /dev/null @@ -1,73 +0,0 @@ -from typing import TYPE_CHECKING, Optional -import wx -import weakref - -from .goto import show_goto -from amulet_map_editor.amulet_wx.simple import SimpleChoiceAny - -if TYPE_CHECKING: - from amulet_map_editor.programs.edit.canvas.controllable_canvas import ControllableEditCanvas - from amulet.api.world import World - - -class FilePanel(wx.Panel): - def __init__(self, canvas: 'ControllableEditCanvas', world: 'World', undo_evt, redo_evt, save_evt, close_evt): - wx.Panel.__init__(self, canvas) - self._canvas = weakref.ref(canvas) - self._world = weakref.ref(world) - - top_sizer = wx.BoxSizer(wx.HORIZONTAL) - - self._location_button = wx.Button(self, label=', '.join([f'{s:.2f}' for s in self._canvas().camera_location])) - self._location_button.Bind(wx.EVT_BUTTON, lambda evt: self.show_goto()) - - top_sizer.Add(self._location_button, 0, wx.ALL | wx.CENTER, 5) - - dim_label = wx.StaticText(self, label="Dimension:") - self._dim_options = SimpleChoiceAny(self) - self._dim_options.SetItems(self._world().world_wrapper.dimensions) - self._dim_options.SetValue("overworld") - self._dim_options.Bind(wx.EVT_CHOICE, self._on_dimension_change) - - top_sizer.Add(dim_label, 0, wx.ALL | wx.CENTER, 5) - top_sizer.Add(self._dim_options, 0, wx.ALL | wx.CENTER, 5) - - def create_button(text, operation): - button = wx.Button(self, label=text) - button.Bind(wx.EVT_BUTTON, operation) - top_sizer.Add(button, 0, wx.ALL, 5) - return button - - self._undo_button: Optional[wx.Button] = create_button('Undo', undo_evt) - self._redo_button: Optional[wx.Button] = create_button('Redo', redo_evt) - self._save_button: Optional[wx.Button] = create_button('Save', save_evt) - create_button('Close', close_evt) - self.update_buttons() - - self.SetSizer(top_sizer) - top_sizer.Fit(self) - self.Layout() - - def update_buttons(self): - self._undo_button.SetLabel(f"Undo | {self._world().chunk_history_manager.undo_count}") - self._redo_button.SetLabel(f"Redo | {self._world().chunk_history_manager.redo_count}") - self._save_button.SetLabel(f"Save | {self._world().chunk_history_manager.unsaved_changes}") - - def _on_dimension_change(self, evt): - self.change_dimension() - evt.Skip() - - def change_dimension(self): - dimension = self._dim_options.GetAny() - if dimension is not None: - self._canvas().dimension = dimension - - def move_event(self, evt): - self._location_button.SetLabel(f'{evt.x:.2f}, {evt.y:.2f}, {evt.z:.2f}') - self.Layout() - self.GetParent().Layout() - - def show_goto(self): - location = show_goto(self, *self._canvas().camera_location) - if location: - self._canvas().camera_location = location diff --git a/amulet_map_editor/programs/edit/ui/tool.py b/amulet_map_editor/programs/edit/ui/tool.py deleted file mode 100644 index 938f9fc1..00000000 --- a/amulet_map_editor/programs/edit/ui/tool.py +++ /dev/null @@ -1,44 +0,0 @@ -import wx - -from amulet_map_editor.programs.edit.events import ( - SelectToolEnabledEvent, - OperationToolEnabledEvent, - ImportToolEnabledEvent, - ExportToolEnabledEvent -) - - -class ToolSelect(wx.Panel): - def __init__(self, canvas): - wx.Panel.__init__(self, canvas) - - self.select_button = wx.Button(self, label="Select") - self.select_button.Bind(wx.EVT_BUTTON, self._select_evt) - self.operation_button = wx.Button(self, label="Operation") - self.operation_button.Bind(wx.EVT_BUTTON, self._operation_evt) - - self.import_button = wx.Button(self, label="Import") - self.import_button.Bind(wx.EVT_BUTTON, self._import_evt) - self.export_button = wx.Button(self, label="Export") - self.export_button.Bind(wx.EVT_BUTTON, self._export_evt) - - sizer = wx.BoxSizer(wx.HORIZONTAL) - sizer.Add(self.select_button) - sizer.Add(self.operation_button) - sizer.Add(self.import_button) - sizer.Add(self.export_button) - self.SetSizer(sizer) - sizer.Fit(self) - self.Layout() - - def _select_evt(self, evt): - wx.PostEvent(self, SelectToolEnabledEvent()) - - def _operation_evt(self, evt): - wx.PostEvent(self, OperationToolEnabledEvent()) - - def _import_evt(self, evt): - wx.PostEvent(self, ImportToolEnabledEvent()) - - def _export_evt(self, evt): - wx.PostEvent(self, ExportToolEnabledEvent()) diff --git a/amulet_map_editor/programs/edit/ui/tool_options/operation/__init__.py b/amulet_map_editor/programs/edit/ui/tool_options/operation/__init__.py deleted file mode 100644 index c0ef5aff..00000000 --- a/amulet_map_editor/programs/edit/ui/tool_options/operation/__init__.py +++ /dev/null @@ -1,108 +0,0 @@ -import wx -import weakref -from typing import Optional, Any, Callable, List - -from amulet.api.structure import Structure - -from .select_destination import SelectDestinationUI -from .select_operation import SelectOperationUI -from .import_tool import SelectImportOperationUI -from .export_tool import SelectExportOperationUI - - -class OperationUI(wx.Panel): - def __init__(self, canvas, world, run_operation, run_main_operation): - wx.Panel.__init__(self, canvas) - - self._world = weakref.ref(world) - self._canvas = weakref.ref(canvas) - self._run_main_operation = run_main_operation - - middle_sizer = wx.BoxSizer(wx.VERTICAL) - - # UI to select which operation to run - self._operation_ui: Optional[SelectOperationUI] = SelectOperationUI(self, world, run_operation) - middle_sizer.Add(self._operation_ui) - self._operation_ui.Layout() - self._operation_ui.Fit() - self._operation_ui.Hide() - - # UI to select which operation to run - self._import_ui: Optional[SelectImportOperationUI] = SelectImportOperationUI(self, world, run_operation) - middle_sizer.Add(self._import_ui) - self._import_ui.Layout() - self._import_ui.Fit() - self._import_ui.Hide() - - # UI to select which operation to run - self._export_ui: Optional[SelectExportOperationUI] = SelectExportOperationUI(self, world, run_operation) - middle_sizer.Add(self._export_ui) - self._export_ui.Layout() - self._export_ui.Fit() - self._export_ui.Hide() - - # UI to select where to put a structure - self._select_destination_ui: Optional[SelectDestinationUI] = SelectDestinationUI( - self, - self._destination_select_cancel, - self._destination_select_confirm, - canvas.structure_locations - ) - middle_sizer.Add(self._select_destination_ui) - self._select_destination_ui.Layout() - self._select_destination_ui.Fit() - self._select_destination_ui.Hide() - - self._shown_ui = self._operation_ui - - self.SetSizer(middle_sizer) - middle_sizer.Fit(self) - self.Layout() - - def _hide_all(self): - self._operation_ui.Hide() - self._select_destination_ui.Hide() - self._import_ui.Hide() - self._export_ui.Hide() - - def _enable_operation_ui(self, ui: wx.Window): - self._hide_all() - self._shown_ui = ui - self.Show() - ui.Show() - self._canvas().select_mode = 1 - self.Fit() - - def enable_operation_ui(self): - self._enable_operation_ui(self._operation_ui) - - def enable_import_ui(self): - self._enable_operation_ui(self._import_ui) - - def enable_export_ui(self): - self._enable_operation_ui(self._export_ui) - - def enable_select_destination_ui(self, operation_path: Any, operation: Callable, operation_input_definitions: List[str], structure: Structure, options: dict): - self._select_destination_ui.setup(operation_path, operation, operation_input_definitions, structure, options) - self._hide_all() - self.Show() - self._select_destination_ui.Show() - self.Fit() - self._canvas().structure = structure - self._canvas().select_mode = 2 - - def _destination_select_cancel(self): - self.enable_operation_ui() - - def _destination_select_confirm(self, *args, **kwargs): - self._select_destination_ui.Disable() - self._run_main_operation(*args, **kwargs) - self._select_destination_ui.Enable() - self.enable_operation_ui() - - def hide(self): - self.Hide() - - @property - def operation(self): - return self._shown_ui.operation diff --git a/amulet_map_editor/programs/edit/ui/tool_options/operation/export_tool.py b/amulet_map_editor/programs/edit/ui/tool_options/operation/export_tool.py deleted file mode 100644 index 00ef3e5b..00000000 --- a/amulet_map_editor/programs/edit/ui/tool_options/operation/export_tool.py +++ /dev/null @@ -1,8 +0,0 @@ -from .select_operation import BaseSelectOperationUI -from amulet_map_editor import plugins - - -class SelectExportOperationUI(BaseSelectOperationUI): - @property - def _operations(self): - return plugins.export_operations diff --git a/amulet_map_editor/programs/edit/ui/tool_options/operation/import_tool.py b/amulet_map_editor/programs/edit/ui/tool_options/operation/import_tool.py deleted file mode 100644 index b3de550f..00000000 --- a/amulet_map_editor/programs/edit/ui/tool_options/operation/import_tool.py +++ /dev/null @@ -1,8 +0,0 @@ -from .select_operation import BaseSelectOperationUI -from amulet_map_editor import plugins - - -class SelectImportOperationUI(BaseSelectOperationUI): - @property - def _operations(self): - return plugins.import_operations diff --git a/amulet_map_editor/programs/edit/ui/tool_options/operation/select_destination.py b/amulet_map_editor/programs/edit/ui/tool_options/operation/select_destination.py deleted file mode 100644 index 18575a8f..00000000 --- a/amulet_map_editor/programs/edit/ui/tool_options/operation/select_destination.py +++ /dev/null @@ -1,77 +0,0 @@ -import wx -import numpy -from typing import Optional, List, Callable, Type, Any - -from amulet.api.structure import Structure -from amulet_map_editor.amulet_wx.simple import SimplePanel - - -class SelectDestinationUI(SimplePanel): - def __init__(self, parent, cancel_callback, confirm_callback, locations: List[numpy.ndarray]): - super().__init__(parent) - self._cancel_callback = cancel_callback - self._confirm_callback = confirm_callback - self._locations = locations - - self._operation_path: Optional[str] = None - self._operation: Optional[Callable] = None - self._operation_input_definitions: Optional[List[str]] = None - self._structure: Optional[Structure] = None - self._options: Optional[dict] = None - - self._x: wx.SpinCtrl = self._add_row('x', wx.SpinCtrl, min=-30000000, max=30000000) - self._y: wx.SpinCtrl = self._add_row('y', wx.SpinCtrl, min=-30000000, max=30000000) - self._z: wx.SpinCtrl = self._add_row('z', wx.SpinCtrl, min=-30000000, max=30000000) - self._copy_air: wx.CheckBox = self._add_row('Copy Air', wx.CheckBox) - self._x.Bind(wx.EVT_SPINCTRL, self._on_location_change) - self._y.Bind(wx.EVT_SPINCTRL, self._on_location_change) - self._z.Bind(wx.EVT_SPINCTRL, self._on_location_change) - - sizer = wx.BoxSizer(wx.HORIZONTAL) - self.add_object(sizer, 0, 0) - self._cancel = wx.Button(self, label="Cancel") - sizer.Add(self._cancel, flag=wx.CENTER, border=5) - self._confirm = wx.Button(self, label="Confirm") - sizer.Add(self._confirm, flag=wx.CENTER, border=5) - - self._cancel.Bind(wx.EVT_BUTTON, self._on_cancel) - self._confirm.Bind(wx.EVT_BUTTON, self._on_confirm) - - def setup(self, operation_path: Any, operation: Callable, operation_input_definitions: List[str], structure: Structure, options: dict): - self._operation_path = operation_path - self._operation = operation - self._operation_input_definitions = operation_input_definitions - self._structure = structure - self._locations.clear() - self._locations.append(structure.selection.min) - self._x.SetValue(structure.selection.min[0]) - self._y.SetValue(structure.selection.min[1]) - self._z.SetValue(structure.selection.min[2]) - self._copy_air.SetValue(options.get('copy_air', False)) - self._options = options - - def _add_row(self, label: str, wx_object: Type[wx.Object], **kwargs) -> Any: - sizer = wx.BoxSizer(wx.HORIZONTAL) - self.add_object(sizer, 0, 0) - name_text = wx.StaticText(self, label=label) - sizer.Add(name_text, flag=wx.CENTER | wx.ALL | wx.EXPAND, border=5) - obj = wx_object(self, **kwargs) - sizer.Add(obj, flag=wx.CENTER | wx.ALL, border=5) - return obj - - def _on_location_change(self, evt): - self._locations[-1] = numpy.array([self._x.GetValue(), self._y.GetValue(), self._z.GetValue()]) - - def _on_cancel(self, evt): - self._cancel_callback() - - def _on_confirm(self, evt): - self._options['location'] = (self._x.GetValue(), self._y.GetValue(), self._z.GetValue()) - self._options['copy_air'] = self._copy_air.GetValue() - self._confirm_callback( - self._operation_path, - self._operation, - self._operation_input_definitions, - options=self._options, - structure=self._structure - ) diff --git a/amulet_map_editor/programs/edit/ui/tool_options/operation/select_operation.py b/amulet_map_editor/programs/edit/ui/tool_options/operation/select_operation.py deleted file mode 100644 index 462512d3..00000000 --- a/amulet_map_editor/programs/edit/ui/tool_options/operation/select_operation.py +++ /dev/null @@ -1,76 +0,0 @@ -import wx -import weakref -from typing import TYPE_CHECKING, Callable, Dict - -from amulet_map_editor import log -from amulet_map_editor.amulet_wx.simple import SimplePanel, SimpleChoiceAny -from amulet_map_editor import plugins - -if TYPE_CHECKING: - from amulet.api.world import World - - -class BaseSelectOperationUI(SimplePanel): - def __init__(self, parent, world: 'World', run_operation: Callable): - super().__init__(parent) - self._world = weakref.ref(world) - self._operation_choice = SimpleChoiceAny(self) - self._operation_choice.SetItems({key: value["name"] for key, value in self._operations.items()}) - self._operation_choice.Bind(wx.EVT_CHOICE, self._operation_selection_change) - self.add_object(self._operation_choice, 0, wx.ALL | wx.EXPAND) - self._options_button = wx.Button( - self, - label="Change Options" - ) - run_button = wx.Button( - self, - label="Run Operation" - ) - self._options_button.Bind(wx.EVT_BUTTON, self._change_options) - run_button.Bind(wx.EVT_BUTTON, run_operation) - self.add_object(self._options_button, 0, wx.ALL | wx.EXPAND) - self.add_object(run_button, 0, wx.ALL | wx.EXPAND) - self._operation_selection_change_() - - @property - def _operations(self) -> Dict[str, dict]: - raise NotImplementedError - - @property - def operation(self) -> str: - return self._operation_choice.GetAny() - - def _operation_selection_change(self, evt): - self._operation_selection_change_() - evt.Skip() - - def _operation_selection_change_(self): - operation_path = self._operation_choice.GetAny() - if operation_path: - operation = self._operations[operation_path] - if "options" in operation.get("features", []) or "wxoptions" in operation.get("features", []): - self._options_button.Enable() - else: - self._options_button.Disable() - else: - self._options_button.Disable() - - def _change_options(self, evt): - operation_path = self._operation_choice.GetAny() - if operation_path: - operation = self._operations[operation_path] - if "options" in operation.get("features", []): - pass # TODO: implement this - elif "wxoptions" in operation.get("features", []): - options = operation["wxoptions"](self, self._world(), plugins.options.get(operation_path, {})) - if isinstance(options, dict): - plugins.options[operation_path] = options - else: - log.error(f"Plugin {operation['name']} at {operation_path} did not return options in a valid format") - evt.Skip() - - -class SelectOperationUI(BaseSelectOperationUI): - @property - def _operations(self) -> Dict[str, dict]: - return plugins.operations diff --git a/amulet_map_editor/programs/edit/ui/tool_options/select.py b/amulet_map_editor/programs/edit/ui/tool_options/select.py deleted file mode 100644 index 765bf1a3..00000000 --- a/amulet_map_editor/programs/edit/ui/tool_options/select.py +++ /dev/null @@ -1,90 +0,0 @@ -from typing import TYPE_CHECKING, Type, Any -import wx -import weakref - -from amulet_map_editor.programs.edit.events import ( - EVT_BOX_GREEN_CORNER_CHANGE, - EVT_BOX_BLUE_CORNER_CHANGE, - EVT_BOX_COORDS_ENABLE -) - -if TYPE_CHECKING: - from amulet_map_editor.programs.edit.canvas.controllable_canvas import ControllableEditCanvas - - -class SelectOptions(wx.Panel): - def __init__(self, canvas: 'ControllableEditCanvas'): - wx.Panel.__init__(self, canvas) - self._canvas = weakref.ref(canvas) - self._sizer = wx.BoxSizer(wx.VERTICAL) - self.SetSizer(self._sizer) - - # self._x1: wx.SpinCtrl = self._add_row('x1', wx.SpinCtrl, min=-30000000, max=30000000) - # self._y1: wx.SpinCtrl = self._add_row('y1', wx.SpinCtrl, min=-30000000, max=30000000) - # self._z1: wx.SpinCtrl = self._add_row('z1', wx.SpinCtrl, min=-30000000, max=30000000) - # self._x1.Bind(wx.EVT_SPINCTRL, self._green_corner_input_change) - # self._y1.Bind(wx.EVT_SPINCTRL, self._green_corner_input_change) - # self._z1.Bind(wx.EVT_SPINCTRL, self._green_corner_input_change) - # - # self._x2: wx.SpinCtrl = self._add_row('x2', wx.SpinCtrl, min=-30000000, max=30000000) - # self._y2: wx.SpinCtrl = self._add_row('y2', wx.SpinCtrl, min=-30000000, max=30000000) - # self._z2: wx.SpinCtrl = self._add_row('z2', wx.SpinCtrl, min=-30000000, max=30000000) - # self._x2.Bind(wx.EVT_SPINCTRL, self._blue_corner_input_change) - # self._y2.Bind(wx.EVT_SPINCTRL, self._blue_corner_input_change) - # self._z2.Bind(wx.EVT_SPINCTRL, self._blue_corner_input_change) - # - # self._x1.Disable() - # self._y1.Disable() - # self._z1.Disable() - # self._x2.Disable() - # self._y2.Disable() - # self._z2.Disable() - # - # self._x1.SetBackgroundColour((160, 215, 145)) - # self._y1.SetBackgroundColour((160, 215, 145)) - # self._z1.SetBackgroundColour((160, 215, 145)) - # - # self._x2.SetBackgroundColour((150, 150, 215)) - # self._y2.SetBackgroundColour((150, 150, 215)) - # self._z2.SetBackgroundColour((150, 150, 215)) - # - # self._canvas().Bind(EVT_BOX_GREEN_CORNER_CHANGE, self._green_corner_renderer_change) - # self._canvas().Bind(EVT_BOX_BLUE_CORNER_CHANGE, self._blue_corner_renderer_change) - # self._canvas().Bind(EVT_BOX_COORDS_ENABLE, self._enable_scrolls) - - def enable(self): - self._canvas().select_mode = 0 - self.Show() - - def _add_row(self, label: str, wx_object: Type[wx.Object], **kwargs) -> Any: - sizer = wx.BoxSizer(wx.HORIZONTAL) - self._sizer.Add(sizer, 0, 0) - name_text = wx.StaticText(self, label=label) - sizer.Add(name_text, flag=wx.CENTER | wx.ALL | wx.EXPAND, border=5) - obj = wx_object(self, **kwargs) - sizer.Add(obj, flag=wx.CENTER | wx.ALL, border=5) - return obj - - # def _green_corner_input_change(self, _): - # self._canvas().active_selection.point1 = [self._x1.GetValue(), self._y1.GetValue(), self._z1.GetValue()] - # - # def _blue_corner_input_change(self, _): - # self._canvas().active_selection.point2 = [self._x2.GetValue(), self._y2.GetValue(), self._z2.GetValue()] - # - # def _green_corner_renderer_change(self, evt): - # self._x1.SetValue(evt.x) - # self._y1.SetValue(evt.y) - # self._z1.SetValue(evt.z) - # - # def _blue_corner_renderer_change(self, evt): - # self._x2.SetValue(evt.x) - # self._y2.SetValue(evt.y) - # self._z2.SetValue(evt.z) - # - # def _enable_scrolls(self, evt): - # self._x1.Enable(evt.enabled) - # self._y1.Enable(evt.enabled) - # self._z1.Enable(evt.enabled) - # self._x2.Enable(evt.enabled) - # self._y2.Enable(evt.enabled) - # self._z2.Enable(evt.enabled) diff --git a/amulet_map_editor/version b/amulet_map_editor/version index 6620cc43..0310bb2e 100644 --- a/amulet_map_editor/version +++ b/amulet_map_editor/version @@ -1 +1 @@ -0.6.7.0 \ No newline at end of file +0.6.8.0 \ No newline at end of file