├── .gitattributes ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── __init__.py ├── addon ├── HotkeyPrefs.JSON ├── __init__.py ├── keymaps │ ├── __init__.py │ ├── event_handler.py │ ├── keymap.py │ └── modal_keymapping.py ├── ops │ ├── __init__.py │ ├── abs_edge_loop.py │ ├── actions │ │ ├── abs_insert_loop.py │ │ ├── activate_edge_slide.py │ │ ├── insert_loop_base.py │ │ ├── insert_multi_loop.py │ │ ├── insert_single_loop.py │ │ ├── remove_loop.py │ │ ├── right_click.py │ │ └── scale_loops.py │ ├── edge_constraint.py │ ├── edge_data.py │ ├── edge_ring.py │ ├── edge_slide.py │ ├── fast_loop.py │ ├── fast_loop_actions.py │ ├── fast_loop_algorithms.py │ ├── fast_loop_common.py │ ├── fast_loop_helpers.py │ ├── internal.py │ ├── loop_slice.py │ └── multi_object_edit.py ├── props │ ├── __init__.py │ ├── addon.py │ ├── fl_properties.py │ └── prefs.py ├── shaders │ ├── icon_minimize_fs.glsl │ ├── icon_test_fs.glsl │ └── icon_vs.glsl ├── snapping │ ├── snap_math.py │ ├── snap_points.py │ ├── snapping.py │ └── snapping_utils.py ├── tools │ ├── __init__.py │ ├── fast_loop.py │ └── icons │ │ ├── fl.fast_loop.dat │ │ └── fl.fast_loop_classic.dat ├── ui │ ├── __init__.py │ ├── gizmos │ │ ├── __init__.py │ │ └── gizmo_snapping.py │ ├── panels.py │ ├── ui.py │ └── widgets.py └── utils │ ├── __init__.py │ ├── common.py │ ├── draw_2d.py │ ├── draw_3d.py │ ├── edge_slide.py │ ├── math.py │ ├── mesh.py │ ├── observer.py │ ├── ops.py │ ├── raycast.py │ ├── safety.py │ ├── serialization.py │ ├── shaders.py │ └── ui.py ├── bl_ui_widgets ├── .gitignore ├── .vscode │ └── settings.json ├── LICENSE.txt ├── README.md ├── __init__.py ├── bl_ui_button.py ├── bl_ui_checkbox.py ├── bl_ui_drag_panel.py ├── bl_ui_draw_op.py ├── bl_ui_label.py ├── bl_ui_slider.py ├── bl_ui_textbox.py ├── bl_ui_up_down.py ├── bl_ui_widget.py ├── drag_panel.blend ├── drag_panel.blend1 ├── drag_panel_op.py └── img │ ├── rotate.png │ ├── rotate.psd │ ├── scale.png │ ├── scale.psd │ └── scale_24.png ├── blender_manifest.toml └── signalslot ├── .coveragerc ├── .travis.yml ├── CHANGELOG ├── LICENSE ├── MANIFEST.in ├── README ├── README.rst ├── docs ├── Makefile ├── conf.py ├── index.rst ├── make.bat ├── pattern.rst └── usage.rst ├── setup.cfg ├── setup.py ├── signalslot ├── __init__.py ├── contrib │ ├── __init__.py │ └── task │ │ ├── __init__.py │ │ ├── task.py │ │ └── test.py ├── exceptions.py ├── signal.py ├── slot.py └── tests.py ├── test_requirements.txt └── tox.ini /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # celery beat schedule file 95 | celerybeat-schedule 96 | 97 | # SageMath parsed files 98 | *.sage.py 99 | 100 | # Environments 101 | .env 102 | .venv 103 | env/ 104 | venv/ 105 | ENV/ 106 | env.bak/ 107 | venv.bak/ 108 | 109 | # Spyder project settings 110 | .spyderproject 111 | .spyproject 112 | 113 | # Rope project settings 114 | .ropeproject 115 | 116 | # mkdocs documentation 117 | /site 118 | 119 | # mypy 120 | .mypy_cache/ 121 | .dmypy.json 122 | dmypy.json 123 | 124 | # Pyre type checker 125 | .pyre/ 126 | .vscode/settings.json 127 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "C:\\Users\\Jrome\\AppData\\Local\\Programs\\Python\\Python37\\python.exe" 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Shout-out to Hologram from the BA forums for feedback and taking time to test the addon. 2 | Rename the addon folder to Fast Loop. If you don't blender will error when enabling. 3 | 4 | ## Fast Loop 2.0 5 | **Added** 6 | * New sub mode to insert Vertices on edges. 7 | v to enable. 8 | 9 | * New sub mode to use selected edges to guide the loop's direction. 10 | a to enable. 11 | 12 | * UVs are now preserved after loop insertion. 13 | * Auto merge loops when auto merge is enabled. 14 | 15 | * Hold ctrl to snap the loop to either vertices, edges, or the midpoint of edges. (Uses blender snap settings for element types) 16 | 17 | * Shift+ Right click to insert loops in the center. 18 | * Can be changed in addon preferences under keymaps tab. 19 | 20 | * Numerical input used when scaling now works with more complicated input. 21 | Some examples of the input that can be used: 22 | * 1m 2cm 3mm 23 | * 1.2m 24 | * 1 5/16m 25 | * 3cm's 2mms 26 | * 1ft 6in 100thou 27 | * 1' 6" 28 | * 10p (where p is a replacement for %) 29 | 30 | **Removed** 31 | * Select Loop (Ctrl + Click) 32 | * Midpoint sub mode 33 | 34 | **Fixed** 35 | * Loop preview updates correctly when increase/decrease loop count is mapped to the mouse wheel. 36 | * Fixed issues when scene unit scale is anything other than one. 37 | * Better support for industry compatible keymap. (Enable in the addon preferences) 38 | 39 | #### UI/UX 40 | **Added** 41 | * Draggable panel display in the viewport. 42 | * Added a bar to display the distances between edges. 43 | * Display settings can be changed using the HUD settings pop up menu. 44 | * The bar can be resized by clicking and dragging inside the little box to the right. 45 | 46 | Fast Loop now works in 3d viewports other than the one the tool was invoked in. (Quad view not supported) 47 | 48 | **Removed** 49 | * Operator panel in the N panel. 50 | * Pie menu 51 | *Preference option to swap right and left click functionality. 52 | * Should now be Synced with blender's "Select with Mouse Button" setting 53 | 54 | #### Incremental Snapping: 55 | **Added** 56 | * Option to use a distance value to set the space between snap points. 57 | * Tick the use distance checkbox and set a distance to use in the distance input field. 58 | * The Auto option calculates the number of segments to use, based on the distance used. 59 | * If use distance is enabled the following happens: 60 | * Every 4th tick is major tick (Distances are only drawn near major ticks) 61 | * The origin at which the snap points are calculated change sides based on the side the mouse is on. 62 | Lock the snap points x if you wish to prevent them from moving. 63 | * Toggle the center button to change the origin of the snap points to the center. 64 | 65 | ## Edge Slide 66 | #### UI/UX 67 | **Added** 68 | * Draggable panel display in the viewport. 69 | * Highlight the edge closest to the mouse. 70 | 71 | **Removed** 72 | * Integration of edge constrained translation. 73 | * It's now its own operator. 74 | 75 | ## Edge Constrained Translation: 76 | **How To Use** 77 | 1. Select vertices then activate the operator. 78 | 2. Hover the mouse near a vertex. A representation of the axes of the current coordinate system will appear. 79 | 3. Press X(red), Y(green), or Z(blue) to select the corresponding axis to slide along. 80 | 81 | **Added** 82 | * Hold ctrl to snap the postition to either vertices, edges, or the midpoint of edges. (Uses blender snap settings for element types) 83 | * Works with active and nearest snap settings 84 | 85 | **Known Issues** 86 | * If you enable snapping, and then disable it by releasing ctrl, the position of the mouse and vertices' will most likely desync. This should not affect the useability. 87 | 88 | ### Requires the Edge Flow addon: 89 | https://github.com/BenjaminSauder/EdgeFlow 90 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | # This program is free software; you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation; either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful, but 7 | # WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTIBILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 9 | # General Public License for more details. 10 | # 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | 14 | 15 | # bl_info = { 16 | # 'name': 'Fast Loop', 17 | # 'author': 'Jrome', 18 | # 'description': 'Quickly insert loop cuts at the mouse position with a preview.', 19 | # 'blender': (4, 0, 0), 20 | # 'version': (2, 1, 0), 21 | # 'category': 'Mesh', 22 | # } 23 | 24 | from . import addon 25 | 26 | def register(): 27 | addon.register() 28 | 29 | def unregister(): 30 | addon.unregister() 31 | 32 | -------------------------------------------------------------------------------- /addon/HotkeyPrefs.JSON: -------------------------------------------------------------------------------- 1 | {"FL_OT_fast_loop": {"Even": ["E", "PRESS", false, false, false], "Flip": ["F", "PRESS", false, false, false], "Mirrored": ["M", "PRESS", false, false, false], "Perpendicular": ["/", "PRESS", false, false, false], "Multi Loop Offset": ["O", "PRESS", false, false, false], "Loop Spacing": ["W", "PRESS", false, false, false], "Insert Verts": ["V", "PRESS", false, false, false], "Use Selected Edges": ["A", "PRESS", false, false, false], "Snap Points": ["S", "PRESS", false, false, false], "Lock Snap Points": ["X", "PRESS", false, false, false], "Freeze Edge": [",", "PRESS", false, false, false], "Increase Loop Count": ["=", "PRESS", false, false, false], "Decrease Loop Count": ["-", "PRESS", false, false, false], "Insert Loop At Midpoint": ["RIGHTMOUSE", "PRESS", false, true, false]}} -------------------------------------------------------------------------------- /addon/__init__.py: -------------------------------------------------------------------------------- 1 | from . import props 2 | from . import ops 3 | from . import tools 4 | from . import ui 5 | from . ui import gizmos 6 | # from . import keymaps 7 | 8 | modules = ( 9 | props, 10 | ops, 11 | tools, 12 | ui, 13 | gizmos, 14 | # keymaps, Disabled keymap setup for addon. Please manually configure keymappings. 15 | ) 16 | 17 | 18 | def register(): 19 | for module in modules: 20 | module.register() 21 | 22 | 23 | def unregister(): 24 | for module in reversed(modules): 25 | module.unregister() 26 | -------------------------------------------------------------------------------- /addon/keymaps/__init__.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from . import keymap 3 | 4 | 5 | modules = ( 6 | keymap, 7 | ) 8 | 9 | def register(): 10 | keyconfig = bpy.context.window_manager.keyconfigs.addon 11 | 12 | for module in modules: 13 | module.register(keyconfig) 14 | 15 | def unregister(): 16 | keyconfig = bpy.context.window_manager.keyconfigs.addon 17 | 18 | for module in modules: 19 | keyconfig.keymaps.remove(module.keymap) 20 | -------------------------------------------------------------------------------- /addon/keymaps/keymap.py: -------------------------------------------------------------------------------- 1 | 2 | keymap = None 3 | 4 | def register(keyconfig): 5 | global keymap 6 | keymap = keyconfig.keymaps.new(name='Mesh', space_type='EMPTY') 7 | 8 | keymap.keymap_items.new('fl.fast_loop', 'INSERT', 'PRESS', alt=True, repeat=False) 9 | keymap.keymap_items.new('fl.edge_slide', 'ACCENT_GRAVE', 'PRESS', alt=True, repeat=False) 10 | keymap.keymap_items.new('fl.edge_contraint_translation', 'SLASH', 'PRESS', alt=True, repeat=False) 11 | # keymap.keymap_items.new('fl.loop_slice', 'SLASH', 'PRESS', shift=True, repeat=False) 12 | -------------------------------------------------------------------------------- /addon/keymaps/modal_keymapping.py: -------------------------------------------------------------------------------- 1 | import os 2 | from .. import utils 3 | # To add a new modal keymap: 4 | # 1) Add a default keymap to the dict named data in the Method save_keymap() below (modifier key order is: ctrl, shift, alt) 5 | # 2) Add a new bpy.props.StringProperty to the ModalDisplay Class in addon.py 6 | # 3) Add a new entry into the dict returned by get_ordered_fl_keymap_actions() 7 | # where the key is a string with the value of the name of the bpy.props.StringProperty that was created in the ModalDisplay Class 8 | 9 | class ModalKeymap(): 10 | _keymap = {} 11 | _action_to_keymap = {} 12 | _events = set() 13 | def __init__(self, keymap): 14 | self._keymap = {v: k for k, v in keymap.items()} 15 | self._actions = {action for action in keymap.keys()} 16 | 17 | 18 | def update_mapping(self, action, event_type, event_value, ctrl=False, shift=False, alt=False): 19 | keymap_item = (event_type, event_value, ctrl, shift, alt) 20 | action = utils.ui.get_ordered_fl_keymap_actions().get(action, None) 21 | if action is not None: 22 | if action in self._keymap.values(): 23 | if keymap_item in self._keymap: 24 | if action != self._keymap[keymap_item]: 25 | return False 26 | else: 27 | self.remove_mapping(action) 28 | self._keymap[keymap_item] = action 29 | return True 30 | 31 | 32 | def remove_mapping(self, event): 33 | for keymap, keymap_value in self._keymap.items(): 34 | if event == keymap_value: 35 | del self._keymap[keymap] 36 | break 37 | 38 | def get_all_mappings(self): 39 | return self._keymap.items() 40 | 41 | def get_action_from_mapping(self, mapping): 42 | return self._keymap.get(mapping) 43 | 44 | def get_mapping_from_action(self, action): 45 | action_to_keymap = {v: k for k, v in self._keymap.items()} 46 | return action_to_keymap[action] 47 | 48 | def get_valid_keymap_actions(self): 49 | return self._actions 50 | 51 | 52 | class ModalOperatorKeymapCache(): 53 | keymaps = {} 54 | 55 | @classmethod 56 | def get_keymap(cls, operator_id) -> ModalKeymap: 57 | if operator_id in cls.keymaps: 58 | return cls.keymaps[operator_id] 59 | else: 60 | keymap = load_keymap(operator_id) 61 | cls.keymaps[operator_id] = ModalKeymap(keymap) 62 | return cls.keymaps[operator_id] 63 | 64 | @classmethod 65 | def get_all_keymaps(cls): 66 | return cls.keymaps.items() 67 | 68 | 69 | def load_keymap(operator_id): 70 | def repair_data(data): 71 | return {k: tuple(v) for k, v in data.items()} 72 | 73 | directory = os.path.dirname(os.path.abspath(__file__)) 74 | parent_directory = os.path.dirname(directory) 75 | file_path = os.path.join(parent_directory, "HotkeyPrefs.JSON") 76 | 77 | deserializer = utils.serialization.JSONDeserializer(file_path) 78 | data = repair_data(deserializer.deserialize()[operator_id]) 79 | return data 80 | 81 | 82 | def save_keymap(operator_id, modal_keymap: ModalKeymap=None): 83 | directory = os.path.dirname(os.path.abspath(__file__)) 84 | parent_directory = os.path.dirname(directory) 85 | file_path = os.path.join(parent_directory, "HotkeyPrefs.JSON") 86 | 87 | data = None 88 | if modal_keymap is not None: 89 | keymap_data = {v: k for k, v in modal_keymap.get_all_mappings()} 90 | data = {operator_id: 91 | keymap_data 92 | } 93 | else: 94 | data = {operator_id : 95 | {"Even": ('E', 'PRESS', False, False, False), 96 | "Flip": ('F', 'PRESS', False, False, False), 97 | "Mirrored": ('M', 'PRESS', False, False, False), 98 | "Perpendicular": ('/', 'PRESS', False, False, False), 99 | "Multi Loop Offset": ('O', 'PRESS', False, False, False), 100 | "Loop Spacing": ('W', 'PRESS', False, False, False), 101 | "Insert Verts": ('V', 'PRESS', False, False, False), 102 | "Use Selected Edges": ('A', 'PRESS', False, False, False), 103 | "Snap Points": ('S', 'PRESS', False, False, False), 104 | "Lock Snap Points": ('X', 'PRESS', False, False, False), 105 | "Freeze Edge": (',', 'PRESS', False, False, False), 106 | "Increase Loop Count": ('=', 'PRESS', False, False, False), 107 | "Decrease Loop Count": ('-', 'PRESS', False, False, False), 108 | "Insert Loop At Midpoint": ('RIGHTMOUSE', 'PRESS', False, True, False) 109 | } 110 | } 111 | 112 | utils.serialization.JSONSerializer(file_path).serialize(data) 113 | 114 | print(f" Saved modal keymap preferences to: {file_path}") 115 | 116 | -------------------------------------------------------------------------------- /addon/ops/__init__.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from . import internal 3 | from . import fast_loop 4 | from . import edge_slide 5 | from . import edge_constraint 6 | from . abs_edge_loop import OT_Absolute_Edge_Loop 7 | 8 | 9 | classes = ( 10 | internal.FastLoopRunner, 11 | internal.UI_OT_override_reset, 12 | internal.UI_OT_reset_operator, 13 | internal.UI_OT_keymap_input_operator, 14 | internal.UI_OT_save_keymap_operator, 15 | internal.UI_OT_distance_display_settings_operator, 16 | internal.UI_OT_AltNavDetected_operator, 17 | fast_loop.FastLoopOperator, 18 | edge_slide.EdgeSlideOperator, 19 | edge_constraint.EdgeConstraintTranslationOperator, 20 | OT_Absolute_Edge_Loop, 21 | ) 22 | 23 | 24 | def register(): 25 | for cls in classes: 26 | bpy.utils.register_class(cls) 27 | 28 | 29 | 30 | def unregister(): 31 | for cls in reversed(classes): 32 | bpy.utils.unregister_class(cls) 33 | -------------------------------------------------------------------------------- /addon/ops/actions/activate_edge_slide.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | if TYPE_CHECKING: 3 | from fast_loop import FastLoopOperator 4 | 5 | import bpy 6 | 7 | from ..edge_slide import EdgeSlideOperator 8 | from ...ops.fast_loop_actions import BaseAction 9 | from ..fast_loop_helpers import (set_mode, Mode) 10 | 11 | from .import insert_single_loop 12 | from .import insert_multi_loop 13 | 14 | class EdgeSlideAction(BaseAction): 15 | Mode = Mode.EDGE_SLIDE 16 | 17 | def __init__(self, context, invoked_by_spacebar) -> None: 18 | self.context: FastLoopOperator = context 19 | self._invoked_by_spacebar = invoked_by_spacebar 20 | 21 | def enter(self): 22 | set_mode(self.Mode) 23 | if self.context.use_snap_points: 24 | self.context.snap_context.disable_increment_mode() 25 | EdgeSlideOperator.register_listener(self, self.edge_slide_finished) 26 | bpy.ops.fl.edge_slide('INVOKE_DEFAULT', restricted=not self._invoked_by_spacebar, invoked_by_fla=True) 27 | 28 | def exit(self): 29 | if self.context.use_snap_points: 30 | self.context.snap_context.enable_increment_mode() 31 | EdgeSlideOperator.unregister_listener(self) 32 | 33 | def update(self): 34 | pass 35 | 36 | def handle_input(self, bl_context, bl_event): 37 | pass 38 | 39 | def on_mouse_move(self, bl_event): 40 | pass 41 | 42 | def draw_3d(self, bl_context): 43 | pass 44 | 45 | def draw_ui(self, bl_context): 46 | pass 47 | 48 | def edge_slide_finished(self, message=None, data=None): 49 | if message is not None and message == "switch_modes": 50 | event = data 51 | num_lookup = {'ONE': 1, 'TWO': 2, 'THREE': 3, 'FOUR': 4, 'FIVE': 5, 'SIX': 6, 'SEVEN': 7, 'EIGHT': 8, 'NINE': 9} 52 | n = num_lookup[event.type] 53 | if n == 1: 54 | self.context.pop_action() 55 | self.context.switch_action(insert_single_loop.InsertSingleLoopAction(self.context)) 56 | else: 57 | self.context.pop_action() 58 | self.context.switch_action(insert_multi_loop.InsertMultiLoopAction(self.context)) 59 | self.context.segments = n 60 | else: 61 | self.context.pop_action() 62 | -------------------------------------------------------------------------------- /addon/ops/actions/insert_multi_loop.py: -------------------------------------------------------------------------------- 1 | from . import insert_loop_base 2 | from .import insert_single_loop, scale_loops 3 | from ..fast_loop_algorithms import ComputeEdgePostitonsMultiAlgorithm 4 | from ..fast_loop_helpers import Mode 5 | 6 | 7 | class InsertMultiLoopAction(insert_loop_base.InsertAction): 8 | Mode = Mode.MULTI_LOOP 9 | 10 | def __init__(self, context) -> None: 11 | if context.segments == 1: 12 | context.segments = 2 13 | context.edge_pos_algorithm = ComputeEdgePostitonsMultiAlgorithm() 14 | super().__init__(context) 15 | 16 | 17 | def enter(self): 18 | self.context.main_panel_hud.set_child_visibility_by_name("Multi", True) 19 | self.context.main_panel_hud.set_title_bar_text(f"Multi [{self.context.segments}]") 20 | 21 | self.context.main_panel_hud.layout_widgets() 22 | super().enter() 23 | 24 | def exit(self): 25 | self.context.main_panel_hud.set_child_visibility_by_name("Multi", False) 26 | super().exit() 27 | 28 | 29 | def handle_input(self, bl_context, bl_event): 30 | handled = False 31 | num_lookup = {'ONE': 1, 'TWO': 2, 'THREE': 3, 'FOUR': 4, 'FIVE': 5, 'SIX': 6, 'SEVEN': 7, 'EIGHT': 8, 'NINE': 9, 32 | 'NUMPAD_1': 1, 'NUMPAD_2': 2, 'NUMPAD_3': 3, 'NUMPAD_4': 4, 'NUMPAD_5': 5, 'NUMPAD_6': 6, 'NUMPAD_7': 7, 'NUMPAD_8': 8, 'NUMPAD_9': 9} 33 | n = num_lookup.get(bl_event.type, None) 34 | if n == 1: 35 | self.context.switch_action(insert_single_loop.InsertSingleLoopAction(self.context)) 36 | handled = True 37 | 38 | elif n is not None: 39 | self.context.segments = n 40 | self.context.main_panel_hud.set_title_bar_text(f"Multi [{self.context.segments}]") 41 | handled = True 42 | 43 | if not handled: 44 | handled = super().handle_input(bl_context, bl_event) 45 | 46 | return handled 47 | 48 | 49 | def handle_modal_event(self, bl_context, modal_event, bl_event): 50 | handled = False 51 | 52 | if modal_event == "Decrease Loop Count": 53 | self.context.segments -= 1 54 | if self.context.segments == 1: 55 | self.update() 56 | self.context.switch_action(insert_single_loop.InsertSingleLoopAction(self.context)) 57 | else: 58 | self.context.main_panel_hud.set_title_bar_text(f"Multi [{self.context.segments}]") 59 | self.context.scale = self.CalculateDefaultScaleValue() 60 | 61 | handled = True 62 | 63 | elif modal_event == "Increase Loop Count": 64 | self.update() 65 | self.context.segments += 1 66 | self.context.main_panel_hud.set_title_bar_text(f"Multi [{self.context.segments}]") 67 | self.context.scale = self.CalculateDefaultScaleValue() 68 | handled = True 69 | 70 | elif modal_event == "Loop Spacing": 71 | self.context.push_action(scale_loops.ScaleLoopsAction(self.context, bl_event)) 72 | handled = True 73 | 74 | elif modal_event in {"Multi Loop Offset"}: 75 | self.context.use_multi_loop_offset = not self.context.use_multi_loop_offset 76 | handled = True 77 | 78 | if not handled: 79 | handled = super().handle_modal_event(bl_context, modal_event, bl_event) 80 | 81 | return handled 82 | 83 | 84 | def CalculateDefaultScaleValue(self) -> float: 85 | return 1 - (2/(self.context.segments + 1)) -------------------------------------------------------------------------------- /addon/ops/actions/insert_single_loop.py: -------------------------------------------------------------------------------- 1 | 2 | from ..fast_loop_algorithms import ComputeEdgePostitonsSingleAlgorithm 3 | from ..fast_loop_helpers import Mode 4 | 5 | from .import insert_multi_loop 6 | from ..actions.insert_loop_base import InsertAction 7 | 8 | 9 | class InsertSingleLoopAction(InsertAction): 10 | Mode = Mode.SINGLE 11 | 12 | def __init__(self, context) -> None: 13 | context.segments = 1 14 | context.edge_pos_algorithm = ComputeEdgePostitonsSingleAlgorithm() 15 | super().__init__(context) 16 | 17 | 18 | def enter(self): 19 | self.context.main_panel_hud.set_child_visibility_by_name("Single", True) 20 | self.context.main_panel_hud.set_title_bar_text("Single") 21 | self.context.main_panel_hud.layout_widgets() 22 | super().enter() 23 | 24 | 25 | def exit(self): 26 | self.context.main_panel_hud.set_child_visibility_by_name("Single", False) 27 | super().exit() 28 | 29 | 30 | def handle_input(self, bl_context, bl_event): 31 | handled = False 32 | 33 | num_lookup = {'ONE': 1, 'TWO': 2, 'THREE': 3, 'FOUR': 4, 'FIVE': 5, 'SIX': 6, 'SEVEN': 7, 'EIGHT': 8, 'NINE': 9, 34 | 'NUMPAD_1': 1, 'NUMPAD_2': 2, 'NUMPAD_3': 3, 'NUMPAD_4': 4, 'NUMPAD_5': 5, 'NUMPAD_6': 6, 'NUMPAD_7': 7, 'NUMPAD_8': 8, 'NUMPAD_9': 9} 35 | n = num_lookup.get(bl_event.type, None) 36 | if n is not None and n != 1: 37 | self.context.segments = n 38 | self.context.switch_action(insert_multi_loop.InsertMultiLoopAction(self.context)) 39 | 40 | handled = True 41 | 42 | if not handled: 43 | handled = super().handle_input(bl_context, bl_event) 44 | 45 | return handled 46 | 47 | 48 | def handle_modal_event(self, bl_context, modal_event, bl_event): 49 | handled = False 50 | 51 | if modal_event == "Increase Loop Count": 52 | self.update() 53 | self.context.switch_action(insert_multi_loop.InsertMultiLoopAction(self.context)) 54 | handled = True 55 | 56 | if not handled: 57 | handled = super().handle_modal_event(bl_context, modal_event, bl_event) 58 | 59 | return handled 60 | 61 | -------------------------------------------------------------------------------- /addon/ops/actions/remove_loop.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | if TYPE_CHECKING: 3 | from ..fast_loop_common import FastLoopCommon 4 | 5 | import bpy 6 | from bmesh.types import BMEdge 7 | from bmesh import ops, update_edit_mesh 8 | 9 | from ...ops.fast_loop_actions import BaseAction 10 | from ...utils.ops import (get_m_button_map as btn) 11 | from ...utils import draw_3d, common 12 | from ...utils.mesh import (bmesh_edge_loop_walker, get_vertex_shared_by_edges) 13 | 14 | from ..fast_loop_helpers import (set_mode, Mode) 15 | 16 | 17 | class RemoveLoopAction(BaseAction): 18 | Mode = Mode.REMOVE_LOOP 19 | remove_loop_draw_points = [] 20 | 21 | def __init__(self, context) -> None: 22 | self.context: FastLoopCommon = context 23 | 24 | 25 | def enter(self): 26 | set_mode(self.Mode) 27 | 28 | 29 | def exit(self): 30 | if self.context.dirty_mesh: 31 | bpy.ops.object.mode_set(mode='OBJECT') 32 | bpy.ops.object.mode_set(mode='EDIT') 33 | self.context.dirty_mesh = False 34 | 35 | 36 | def update(self): 37 | current_edge = self.context.current_edge 38 | if current_edge is None or not current_edge.is_valid: #or self.context.current_face_index is None: 39 | return 40 | self.remove_loop_draw_points = self.compute_remove_loop_draw_points() 41 | 42 | 43 | def handle_input(self, bl_context, bl_event): 44 | handled = False 45 | if bl_event.type in {'RIGHTMOUSE', 'LEFTMOUSE'}: 46 | if self.context.current_edge is not None and bl_event.type in {btn('LEFTMOUSE')} and bl_event.value == 'CLICK': 47 | self.context.freeze_edge = False 48 | self.remove_edge_loop() 49 | bpy.ops.ed.undo_push(message="Remove Edge Loop") 50 | handled = True 51 | 52 | if not bl_event.ctrl and (bl_event.type in {'RIGHT_SHIFT', 'LEFT_SHIFT'} and bl_event.value == 'RELEASE'): 53 | self.context.pop_action() 54 | handled = True 55 | elif not bl_event.shift and (bl_event.type in {'RIGHT_CTRL', 'LEFT_CTRL'} and bl_event.value == 'RELEASE'): 56 | self.context.pop_action() 57 | handled = True 58 | 59 | return handled 60 | 61 | 62 | def on_mouse_move(self, bl_event): 63 | pass 64 | 65 | 66 | def draw_3d(self, bl_context): 67 | if self.remove_loop_draw_points: 68 | draw_3d.draw_lines(self.remove_loop_draw_points, line_color=(1.0, 0.0, 0.0, 0.9), depth_test=common.prefs().occlude_lines) 69 | self.remove_loop_draw_points.clear() 70 | 71 | 72 | def remove_edge_loop(self): 73 | bm = self.context.ensure_bmesh_(self.context.active_object) 74 | bm.edges.ensure_lookup_table() 75 | current_edge = self.context.current_edge 76 | dissolve_edges = list(bmesh_edge_loop_walker(current_edge)) 77 | 78 | ops.dissolve_edges(bm, edges=dissolve_edges, use_verts=True) 79 | self.context.dirty_mesh = True 80 | mesh = self.context.active_object.data 81 | update_edit_mesh(mesh) 82 | 83 | 84 | def compute_remove_loop_draw_points(self): 85 | 86 | if self.context.current_edge is None: 87 | return 88 | 89 | points = [] 90 | 91 | world_mat = self.context.world_mat 92 | loop_edges = [] 93 | edge: BMEdge = self.context.current_edge 94 | for i, loop_edge in enumerate(bmesh_edge_loop_walker(edge)): 95 | 96 | if i >= 1: 97 | vert = get_vertex_shared_by_edges([loop_edge, loop_edges[i-1]]) 98 | #TODO Errors when the vert only has one edge 99 | if vert is not None: 100 | points.append(world_mat @ vert.co) 101 | 102 | loop_edges.append(loop_edge) 103 | 104 | # Add the missing points that need to be drawn 105 | if len(loop_edges) > 1: 106 | 107 | last_vert = get_vertex_shared_by_edges([loop_edges[0], loop_edges[-1]]) 108 | # A loop was found 109 | if last_vert is not None: 110 | points.append(world_mat @ last_vert.co) 111 | points.append(world_mat @ loop_edges[0].other_vert(last_vert).co) 112 | 113 | connecting_vert = get_vertex_shared_by_edges([loop_edges[0], loop_edges[1]]) 114 | if connecting_vert is not None: 115 | points.append(world_mat @ connecting_vert.co) 116 | points.append(world_mat @ loop_edges[1].other_vert(connecting_vert).co) 117 | # It's not a loop 118 | else: 119 | connecting_vert = get_vertex_shared_by_edges([loop_edges[0], loop_edges[1]]) 120 | if connecting_vert is not None: 121 | points.insert(0, world_mat @ loop_edges[0].other_vert(connecting_vert).co) 122 | points.insert(0, world_mat @ connecting_vert.co) 123 | 124 | connecting_vert2 = get_vertex_shared_by_edges([loop_edges[-2], loop_edges[-1]]) 125 | if connecting_vert2 is not None: 126 | points.append(world_mat @ connecting_vert2.co) 127 | points.append(world_mat @ loop_edges[-1].other_vert(connecting_vert2).co) 128 | else: 129 | points.clear() 130 | points.extend([world_mat @ loop_edges[0].verts[0].co, world_mat @ loop_edges[0].verts[1].co]) 131 | 132 | return points -------------------------------------------------------------------------------- /addon/ops/actions/right_click.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | if TYPE_CHECKING: 3 | from fast_loop import FastLoopOperator 4 | 5 | from ...utils.ops import get_m_button_map as btn 6 | from ..fast_loop_actions import BaseAction 7 | 8 | class RightClickAction(BaseAction): 9 | 10 | def __init__(self, context) -> None: 11 | self.context: FastLoopOperator = context 12 | 13 | 14 | def handle_input(self, bl_context, bl_event): 15 | handled = False 16 | if bl_event.type == btn('RIGHTMOUSE') and bl_event.value == 'RELEASE': 17 | self.context.cancelled = True 18 | self.context.pop_action() 19 | handled = True 20 | return handled 21 | 22 | 23 | def draw_ui(self, bl_context): 24 | 25 | if self.context.area_invoked != bl_context.area: 26 | return 27 | 28 | self.context.main_panel_hud.draw() 29 | 30 | if self.context.slider_widget is not None: 31 | self.context.slider_widget.draw() 32 | -------------------------------------------------------------------------------- /addon/ops/actions/scale_loops.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | from typing import TYPE_CHECKING 3 | if TYPE_CHECKING: 4 | from fast_loop import FastLoopOperator 5 | 6 | from mathutils import Vector 7 | 8 | from ...utils.ops import cursor_warp 9 | from ...utils import draw_2d, ui 10 | 11 | from ..edge_data import EdgeData 12 | from ..fast_loop_actions import (DrawLoopsMixin, BaseAction) 13 | 14 | 15 | class ScaleLoopsAction(DrawLoopsMixin, BaseAction): 16 | def __init__(self, context, bl_event) -> None: 17 | self.context: FastLoopOperator = context 18 | context.start_mouse_pos_x = bl_event.mouse_x 19 | context.event_handler.numeric_input_begin(bl_event, self.on_numeric_input_changed) 20 | self.mouse_coords = (bl_event.mouse_region_x, bl_event.mouse_region_y) 21 | self.prev_numeric_input_copy = copy(context.last_numeric_input_results) 22 | self.prev_scale_value = context.scale 23 | self._mouse_updated = False 24 | 25 | 26 | def enter(self): 27 | if self.context.current_edge is None: 28 | self.context.report({'INFO'}, 'No edge near mouse. Cannot enter scale mode.') 29 | self.context.pop_action() 30 | return 31 | 32 | self.context.is_scaling = True 33 | self.context.report({'INFO'}, 'Enter to confirm. Esc to cancel.') 34 | 35 | 36 | def exit(self): 37 | self.context.is_scaling = False 38 | self.context.event_handler.numeric_input_end() 39 | 40 | 41 | def update(self): 42 | if self._mouse_updated: 43 | self.context.last_numeric_input_results = None 44 | self._mouse_updated = False 45 | 46 | 47 | def handle_input(self, bl_context, bl_event): 48 | 49 | handled = False 50 | # Cancelled action. Revert to prev values 51 | if bl_event.type in {'ESC'} and bl_event.value == 'PRESS': 52 | self.context.last_numeric_input_results = copy(self.prev_numeric_input_copy) 53 | self.context.scale = self.prev_scale_value 54 | self.context.pop_action() 55 | handled = True 56 | 57 | elif bl_event.type in {'RET'} and bl_event.value == 'PRESS': 58 | self.context.pop_action() 59 | handled = True 60 | 61 | return handled 62 | 63 | 64 | def on_mouse_move(self, bl_event): 65 | cursor_warp(bl_event) 66 | self.mouse_coords = (bl_event.mouse_region_x, bl_event.mouse_region_y) 67 | delta_x = bl_event.mouse_x - bl_event.mouse_prev_x 68 | delta_x *= 0.001 if bl_event.shift else 0.01 69 | self.context.scale += delta_x 70 | self.update_scale(self.context.scale) 71 | if self.context.update_loops(): 72 | props = self.context.get_all_props_no_snap() 73 | self.context.edge_data = EdgeData().populate_data(self.context.loop_data, props) 74 | self.context.start_mouse_pos_x = bl_event.mouse_x 75 | self._mouse_updated = True 76 | 77 | self.context.update_slider() 78 | 79 | 80 | def handle_modal_event(self, bl_context, modal_event, bl_event): 81 | handled = False 82 | if modal_event == "Loop Spacing": 83 | self.context.pop_action() 84 | handled = True 85 | return handled 86 | 87 | 88 | def draw_ui(self, bl_context): 89 | if self.context.area_invoked != bl_context.area: 90 | return 91 | 92 | if self.context.slider_widget is not None: 93 | self.context.slider_widget.draw() 94 | 95 | self.draw_scale_text(bl_context) 96 | self.context.main_panel_hud.draw() 97 | 98 | 99 | def draw_scale_text(self, context): 100 | if len(self.context.edge_data.points) < 1: 101 | return 102 | 103 | position = Vector((self.mouse_coords)) 104 | position[0] += 5 * ui.get_ui_scale() 105 | position[1] -= 50 * ui.get_ui_scale() 106 | 107 | text_size = 12 * ui.get_ui_scale() 108 | if self.context.last_numeric_input_results is None : 109 | draw_2d.draw_text_on_screen(f"{100*self.context.scale:.3g} %", position, text_size) 110 | else: 111 | value_str = "" 112 | input_str = self.context.last_numeric_input_results.input_string 113 | if self.context.last_numeric_input_results.is_distance: 114 | value_str += "Distance: " + input_str 115 | 116 | draw_2d.draw_text_on_screen(value_str, position, text_size) 117 | 118 | 119 | def on_numeric_input_changed(self, results): 120 | 121 | self.context.last_numeric_input_results = copy(results) 122 | 123 | if results.value is None: 124 | return 125 | self.context.scale = self.context.calculate_scale_value() 126 | 127 | if self.context.update_loops(): 128 | props = self.context.get_all_props_no_snap() 129 | self.context.edge_data = EdgeData().populate_data(self.context.loop_data, props) 130 | self.context.ensure_bmesh_(self.context.active_object) 131 | 132 | self.update_scale(self.context.scale) 133 | self.context.update_slider() 134 | 135 | 136 | def update_scale(self, scale_value): 137 | if self.context.last_numeric_input_results is None or not self.context.last_numeric_input_results.is_distance: 138 | self.context.multi_loop_props.loop_space_value = f"{100*scale_value:.3g} %" 139 | else: 140 | self.context.multi_loop_props.loop_space_value = self.context.last_numeric_input_results.input_string 141 | 142 | -------------------------------------------------------------------------------- /addon/ops/edge_data.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | if TYPE_CHECKING: 4 | from ..props.fl_properties import AllPropsNoSnap 5 | from .fast_loop import FastLoopOperator 6 | from functools import singledispatchmethod 7 | from collections import namedtuple 8 | 9 | 10 | from mathutils.geometry import intersect_point_line 11 | from bmesh.types import BMEdge 12 | 13 | from .edge_ring import EdgeRing, TriFan, SingleLoop, LoopCollection 14 | 15 | EdgeMetaData = namedtuple('ActiveEdgeData','bm_edge points') 16 | class EdgeData(): 17 | def __init__(self): 18 | self.points = [] 19 | # self.distances = [] TODO 20 | self.edges = [] 21 | self.edge_verts = [] 22 | self.first_edge: EdgeMetaData = None 23 | self.other_edge: EdgeMetaData = None 24 | 25 | 26 | @singledispatchmethod 27 | def populate_data(self, loop_collection: EdgeRing, props): 28 | self.calculate_points_on_edges(loop_collection, props) 29 | return self 30 | 31 | @populate_data.register 32 | def _(self, loop_collection: TriFan, props): 33 | self.calculate_points_on_edges(loop_collection, props) 34 | return self 35 | 36 | @populate_data.register 37 | def _(self, loop_collection: SingleLoop, props): 38 | self.calculate_points_on_edge(loop_collection, props) 39 | return self 40 | 41 | 42 | def calculate_points_on_edges(self, data: LoopCollection, props:AllPropsNoSnap)-> EdgeData: 43 | context: FastLoopOperator = data.get_owner() 44 | context.loop_draw_points.clear() 45 | 46 | for i, loop in enumerate(data.get_loops()): 47 | if not loop.is_valid: 48 | return False 49 | 50 | start_vert = loop.vert 51 | end_vert = loop.edge.other_vert(loop.vert) 52 | self.edge_verts.append((start_vert, end_vert)) 53 | self.edges.append(loop.edge) 54 | 55 | flipped = props.common.flipped 56 | opposite_edge = loop.link_loop_next.link_loop_next.edge 57 | active_edge: BMEdge = data.get_active_loop().edge 58 | if not loop.edge.is_manifold and not opposite_edge.is_manifold and loop.edge.index != active_edge.index: 59 | flipped = not flipped 60 | 61 | # Edge is not manifold, being moused over, and it's the first edge in the list 62 | elif not loop.edge.is_manifold and loop.edge.index == active_edge.index and i == 0: 63 | if opposite_edge.is_manifold: 64 | flipped = not flipped 65 | 66 | # Edge is not manifold, not moused over, and it's the first edge in the list 67 | elif not loop.edge.is_manifold and loop.edge.index != active_edge.index and i == 0: 68 | if opposite_edge.is_manifold: 69 | flipped = not flipped 70 | 71 | if context.force_offset_value == -1: 72 | position = context.current_position.world if not context.is_snapping else context.snap_position 73 | start_pos, end_pos = data.get_active_loop_endpoints() 74 | _, factor = intersect_point_line(position, start_pos, end_pos) 75 | 76 | else: 77 | factor = context.force_offset_value 78 | 79 | points_on_edge, is_reversed = context.edge_pos_algorithm.execute(context, props, start_vert.co.copy(), end_vert.co.copy(), factor, flipped) 80 | self.points.append(points_on_edge) 81 | context.loop_draw_points.append(points_on_edge) 82 | 83 | if is_reversed: 84 | points_on_edge = list(reversed(points_on_edge)) 85 | 86 | if loop.edge.index == active_edge.index: 87 | self.first_edge = EdgeMetaData(active_edge, points_on_edge) 88 | 89 | elif loop.edge.index == data.get_other_loop().edge.index: 90 | self.other_edge = EdgeMetaData(loop.edge, points_on_edge) 91 | 92 | 93 | def calculate_points_on_edge(self, data: LoopCollection, props:AllPropsNoSnap)-> EdgeData: 94 | context: FastLoopOperator = data.get_owner() 95 | context.loop_draw_points.clear() 96 | loop = data.get_loops()[0] 97 | self.edges.append(loop.edge) 98 | 99 | start_vert = loop.vert 100 | end_vert = loop.edge.other_vert(loop.vert) 101 | self.edge_verts.append((start_vert, end_vert)) 102 | 103 | if context.force_offset_value == -1: 104 | position = context.current_position.world if not context.is_snapping else context.snap_position 105 | start_pos, end_pos = data.get_active_loop_endpoints() 106 | _, factor = intersect_point_line(position, start_pos, end_pos) 107 | else: 108 | factor = context.force_offset_value 109 | 110 | points_on_edge, is_reversed = context.edge_pos_algorithm.execute(context, props, start_vert.co.copy(), end_vert.co.copy(), factor, context.flipped) 111 | active_edge: BMEdge = data.get_active_loop().edge 112 | if is_reversed: 113 | points_on_edge = list(reversed(points_on_edge)) 114 | self.first_edge = EdgeMetaData(active_edge, points_on_edge) 115 | self.other_edge = EdgeMetaData(active_edge, points_on_edge) 116 | 117 | self.points.append(points_on_edge) 118 | context.loop_draw_points.append(points_on_edge) -------------------------------------------------------------------------------- /addon/ops/edge_ring.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from abc import ABCMeta 3 | from typing import List, TYPE_CHECKING 4 | if TYPE_CHECKING: 5 | from ..props.fl_properties import AllPropsNoSnap 6 | 7 | from collections import namedtuple 8 | 9 | from ..utils.mesh import (WalkerMetadata,bmesh_edge_ring_walker, bmesh_edge_ring_walker_sel_only, 10 | bm_tri_fan_walker, is_ngon, get_face_from_index, get_face_loop_for_edge, is_tri_fan) 11 | 12 | LoopEndpoints = namedtuple('LoopEndpoints','start end') 13 | 14 | 15 | class LoopCollection(metaclass=ABCMeta): 16 | def __init__(self): 17 | self._owner = None 18 | self._active_loop = None 19 | self._other_loop = None 20 | self._active_face = None 21 | self._loops = [] 22 | self._is_loop = False 23 | self._shortest_len = None 24 | self._loop_endpoints = None 25 | 26 | def set_owner(self, owner): 27 | self._owner = owner 28 | 29 | 30 | def get_owner(self): 31 | return self._owner 32 | 33 | 34 | def get_active_loop(self): 35 | return self._active_loop 36 | 37 | 38 | def set_active_loop(self, value): 39 | self._active_loop = value 40 | 41 | 42 | def set_other_loop(self, active_loop): 43 | self._other_loop = active_loop.link_loop_next.link_loop_next 44 | 45 | 46 | def get_other_loop(self): 47 | return self._other_loop 48 | 49 | 50 | def get_active_face(self): 51 | return self._active_face 52 | 53 | 54 | def set_active_face(self, value): 55 | self._active_face = value 56 | 57 | 58 | def get_loops(self): 59 | return self._loops 60 | 61 | 62 | def set_loop_data(self, value: List): 63 | self._loops = value 64 | 65 | 66 | def get_is_loop(self): 67 | return self._is_loop 68 | 69 | 70 | def set_is_loop(self, value: bool): 71 | self._is_loop = value 72 | 73 | 74 | def get_shortest_edge_len(self): 75 | return self._shortest_len 76 | 77 | 78 | def set_shortest_edge_len(self, value): 79 | self._shortest_len = value 80 | 81 | 82 | def get_active_loop_endpoints(self): 83 | return self._loop_endpoints 84 | 85 | 86 | def set_active_loop_endpoints(self, start, end): 87 | self._loop_endpoints = LoopEndpoints(start, end) 88 | 89 | 90 | def is_single_loop(self): 91 | return len(self._loops) == 1 92 | 93 | 94 | class EdgeRing(LoopCollection): 95 | pass 96 | 97 | class TriFan(LoopCollection): 98 | pass 99 | 100 | class SingleLoop(LoopCollection): 101 | pass 102 | 103 | 104 | #TODO Put into own module 105 | #TODO Only instantiate Loop collection when the data changes 106 | class EdgeDataFactory(): 107 | 108 | @staticmethod 109 | def create(start_edge, context): 110 | current_edge = start_edge 111 | selected_only = context.insert_on_selected_edges 112 | face = get_face_from_index(context.active_object.bm, context.current_face_index) 113 | if face is None: 114 | return None 115 | 116 | loops = [] 117 | data = None 118 | active_loop = None 119 | 120 | 121 | def get_loop_endpoints(): 122 | start = context.world_mat @ active_loop.vert.co 123 | end = context.world_mat @ active_loop.edge.other_vert(active_loop.vert).co 124 | if context.flipped: 125 | end, start = start, end 126 | 127 | return start, end 128 | 129 | if(is_ngon(context.active_object.bm, context.current_face_index) or context.insert_verts): 130 | active_loop = get_face_loop_for_edge(face, start_edge) 131 | data = SingleLoop() 132 | data.set_owner(context) 133 | data.set_is_loop(False) 134 | data.set_loop_data([active_loop]) 135 | data.set_active_loop(active_loop) 136 | data.set_active_face(face) 137 | data.set_shortest_edge_len(active_loop.edge.calc_length()) 138 | data.set_active_loop_endpoints(*get_loop_endpoints()) 139 | return data 140 | 141 | metadata = WalkerMetadata() 142 | if not selected_only: 143 | active_loop = get_face_loop_for_edge(face, start_edge) 144 | loops = list(bmesh_edge_ring_walker(start_edge, False, metadata)) 145 | else: 146 | loops = list(bmesh_edge_ring_walker_sel_only(current_edge, metadata)) 147 | 148 | if loops and metadata.is_loop: 149 | active_loop = metadata.active_loop 150 | if active_loop is None: 151 | return None 152 | 153 | if is_tri_fan(loops): 154 | data = TriFan() 155 | data.set_owner(context) 156 | data.set_is_loop(True) 157 | data.set_loop_data(loops) 158 | data.set_active_loop(active_loop) 159 | data.set_active_face(face) 160 | data.set_shortest_edge_len(metadata.shortest_edge_len) 161 | data.set_other_loop(get_face_loop_for_edge(face, start_edge)) 162 | data.set_active_loop_endpoints(*get_loop_endpoints()) 163 | return data 164 | 165 | if len(loops) < 2 and not selected_only: 166 | loops = list(bm_tri_fan_walker(context.active_object.bm, context.current_face_index, start_edge, metadata)) 167 | active_loop = metadata.active_loop 168 | if loops[1] is not None and active_loop: 169 | data = TriFan() 170 | data.set_owner(context) 171 | data.set_loop_data(loops) 172 | data.set_shortest_edge_len(metadata.shortest_edge_len) 173 | data.set_active_loop(active_loop) 174 | data.set_active_face(face) 175 | data.set_is_loop(metadata.is_loop) 176 | data.set_other_loop(get_face_loop_for_edge(face, start_edge)) 177 | data.set_active_loop_endpoints(*get_loop_endpoints()) 178 | if metadata.is_loop: 179 | del data.get_loops()[-1] 180 | return data 181 | else: 182 | return None 183 | if loops: 184 | active_loop = metadata.active_loop 185 | if active_loop is None: 186 | return None 187 | data = EdgeRing() 188 | data.set_owner(context) 189 | data.set_is_loop(metadata.is_loop) 190 | data.set_loop_data(loops) 191 | data.set_shortest_edge_len(metadata.shortest_edge_len) 192 | data.set_active_loop(active_loop) 193 | data.set_active_face(face) 194 | data.set_other_loop(get_face_loop_for_edge(face, start_edge)) 195 | data.set_active_loop_endpoints(*get_loop_endpoints()) 196 | return data 197 | else: 198 | return None -------------------------------------------------------------------------------- /addon/ops/fast_loop_actions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from abc import ABCMeta 3 | from contextlib import suppress 4 | 5 | from ..utils.common import prefs 6 | from ..utils import draw_3d 7 | 8 | 9 | class DrawLoopsMixin(): 10 | def draw_3d(self, bl_context): 11 | color = prefs().loop_color 12 | line_width = prefs().line_width 13 | transposed_array = list(map(list, zip(*self.context.loop_draw_points))) 14 | for loop in transposed_array: 15 | if self.context.is_loop: 16 | draw_3d.draw_line_loop(loop, color, line_width, depth_test=prefs().occlude_lines) 17 | else: 18 | # TODO find out and fix the cause of a value exception after placing loops while used selected edges is enabled. 19 | with suppress(ValueError): 20 | draw_3d.draw_line(loop, color, line_width, depth_test=prefs().occlude_lines) 21 | 22 | if prefs().draw_loop_vertices: 23 | draw_3d.draw_points(loop, prefs().vertex_color, prefs().vertex_size, depth_test=prefs().occlude_points) 24 | 25 | start_pos = self.context.loop_data.get_active_loop_endpoints().start if self.context.loop_data is not None else None 26 | if self.context.use_even and start_pos is not None: 27 | draw_3d.draw_point(start_pos, color=(1.0, 0.0, 0.0, 0.4)) 28 | 29 | if self.context.is_single_edge and self.context.loop_draw_points: 30 | 31 | for loop in self.context.loop_draw_points: 32 | with suppress(ValueError): 33 | draw_3d.draw_points(loop, prefs().vertex_color, prefs().vertex_size, depth_test=prefs().occlude_points) 34 | 35 | class DrawDirectionArrowMixin(): 36 | def draw_3d(self, bl_context): 37 | for arrow in self.context.draw_direction_arrow_lines: 38 | arrow.draw() 39 | 40 | 41 | def draw_ui(self, bl_context): 42 | for arrow in self.context.draw_direction_arrow_lines: 43 | arrow.draw_2d() 44 | 45 | 46 | class Actions(): 47 | action_stack = [] 48 | 49 | @property 50 | def current_action(self): 51 | return self.action_stack[-1] 52 | 53 | 54 | def switch_action(self, action): 55 | self.action_stack.pop().exit() 56 | self.push_action(action) 57 | 58 | 59 | def push_action(self, action): 60 | self.action_stack.append(action) 61 | action.enter() 62 | 63 | 64 | def pop_action(self): 65 | self.action_stack.pop().exit() 66 | self.current_action.enter() 67 | 68 | 69 | class BaseAction(metaclass=ABCMeta): 70 | 71 | def enter(self): 72 | pass 73 | 74 | def exit(self): 75 | pass 76 | 77 | def update(self): 78 | pass 79 | 80 | def handle_input(self, bl_context, bl_event): 81 | pass 82 | 83 | def on_mouse_move(self, bl_event): 84 | pass 85 | 86 | def draw_3d(self, bl_context): 87 | pass 88 | 89 | def draw_ui(self, bl_context): 90 | pass -------------------------------------------------------------------------------- /addon/ops/fast_loop_helpers.py: -------------------------------------------------------------------------------- 1 | from .. utils import ops 2 | from enum import Enum 3 | 4 | 5 | class Mode(Enum): 6 | NONE = 0 7 | SINGLE = 4 8 | MULTI_LOOP = 8 9 | REMOVE_LOOP = 16 10 | SELECT_LOOP = 32 11 | EDGE_SLIDE = 64 12 | 13 | enum_to_str = {Mode.SINGLE: 'SINGLE', Mode.MULTI_LOOP: 'MULTI_LOOP', Mode.REMOVE_LOOP: 'REMOVE_LOOP', Mode.SELECT_LOOP: 'SELECT_LOOP', Mode.EDGE_SLIDE: 'EDGE_SLIDE', Mode.NONE: 'NONE'} 14 | str_to_enum = {v: k for k, v in enum_to_str.items()} 15 | def enum_to_mode_str(mode): 16 | return enum_to_str[mode] 17 | 18 | def str_to_mode_enum(mode_str): 19 | return str_to_enum[mode_str] 20 | 21 | def get_active_mode(): 22 | return str_to_enum[ops.options().mode] 23 | 24 | def mode_enabled(mode) -> bool: 25 | active_mode = get_active_mode() 26 | if mode in enum_to_str: 27 | return active_mode == mode 28 | return False 29 | 30 | def get_options(): 31 | return ops.options() 32 | 33 | def set_mode(mode): 34 | ops.set_option('mode', enum_to_str[mode]) 35 | 36 | # def get_options(): 37 | # return options() 38 | 39 | # def set_option(option, value): 40 | # return ops.set_option(option, value) 41 | 42 | def get_props(): 43 | return ops.fl_props() 44 | 45 | def set_prop(prop, value): 46 | return ops.set_fl_prop(prop, value) -------------------------------------------------------------------------------- /addon/ops/multi_object_edit.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | from dataclasses import dataclass 3 | 4 | import bmesh 5 | from bpy.types import Object, Context 6 | from bmesh.types import BMesh 7 | from mathutils import Matrix 8 | 9 | @dataclass 10 | class EditObjectData(): 11 | _bl_object: Object = None 12 | bm: BMesh = None 13 | world_matrix: Matrix = None 14 | inv_world_matrix: Matrix = None 15 | 16 | @property 17 | def name(self): 18 | return self._bl_object.data.name 19 | 20 | @property 21 | def data(self): 22 | return self._bl_object.data 23 | 24 | @property 25 | def get_bl_object(self): 26 | return self._bl_object 27 | 28 | 29 | class MultiObjectEditing(): 30 | """Mixin Do not instantiate this class.""" 31 | 32 | active_object: EditObjectData = None 33 | selected_editable_objects: Dict[str, EditObjectData] = {} 34 | _world_mat: Matrix = None 35 | 36 | @property 37 | def world_mat(self): 38 | return self.active_object.world_matrix 39 | 40 | # @world_mat.setter 41 | # def world_mat(self, value): 42 | # self.active_object.world_matrix = value 43 | 44 | @property 45 | def world_inv(self): 46 | return self.active_object.inv_world_matrix 47 | 48 | # @world_inv.setter 49 | # def world_inv(self, value): 50 | # self.active_object.inv_world_matrix = value 51 | 52 | 53 | def add_selected_editable_objects(self, context: Context): 54 | objects = context.selected_editable_objects 55 | for obj in objects: 56 | if not obj.type in {'MESH'}: 57 | continue 58 | 59 | obj_name = obj.name 60 | if obj.mode not in {'EDIT'}: 61 | if obj.name in self.selected_editable_objects: 62 | del self.selected_editable_objects[obj.data.name] 63 | 64 | obj.update_from_editmode() 65 | mesh = obj.data 66 | bm = bmesh.from_edit_mesh(mesh) 67 | 68 | world_matrix = obj.matrix_world 69 | inv_world_matrix = obj.matrix_world.inverted_safe() 70 | self.selected_editable_objects[obj_name] = EditObjectData(obj, bm, world_matrix, inv_world_matrix) -------------------------------------------------------------------------------- /addon/props/__init__.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from . import addon 3 | from . import prefs 4 | 5 | classes = ( 6 | addon.ModalKeymapDisplay, 7 | prefs.AddonPrefs, 8 | addon.Loop_Cut, 9 | addon.Loop_Cut_Slot_Prop, 10 | addon.Loop_Cut_Slots_Prop, 11 | addon.FL_Props, 12 | addon.FL_Options, 13 | ) 14 | 15 | def register(): 16 | for cls in classes: 17 | bpy.utils.register_class(cls) 18 | 19 | bpy.types.WindowManager.keymap_strings = bpy.props.PointerProperty(type=addon.ModalKeymapDisplay) 20 | 21 | bpy.types.Scene.fl_options = bpy.props.PointerProperty(type=addon.FL_Options) 22 | bpy.types.WindowManager.fl_props = bpy.props.PointerProperty(type=addon.FL_Props) 23 | 24 | bpy.types.WindowManager.Loop_Cut_Slots = bpy.props.PointerProperty(type=addon.Loop_Cut_Slots_Prop) 25 | bpy.types.WindowManager.Loop_Cut_Slots_Index = bpy.props.IntProperty(name='Loop Index', default=0) 26 | bpy.types.WindowManager.Loop_Cut_Lookup_Index = bpy.props.IntProperty(name='Loop Cut Slots Lookup Index', default=0) 27 | 28 | def unregister(): 29 | del bpy.types.WindowManager.keymap_strings 30 | del bpy.types.Scene.fl_options 31 | del bpy.types.WindowManager.fl_props 32 | del bpy.types.WindowManager.Loop_Cut_Slots 33 | del bpy.types.WindowManager.Loop_Cut_Slots_Index 34 | del bpy.types.WindowManager.Loop_Cut_Lookup_Index 35 | 36 | for cls in reversed(classes): 37 | bpy.utils.unregister_class(cls) 38 | -------------------------------------------------------------------------------- /addon/props/fl_properties.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | from .. props import addon 4 | from ..ops.fast_loop_helpers import (get_options) 5 | 6 | AllPropsNoSnap = namedtuple('AllPropsNoSnap', ['common', 'multi_loop', 'sub']) 7 | 8 | class BaseProps(): 9 | @property 10 | def fast_loop_options(self)-> addon.FL_Options: 11 | return get_options() 12 | 13 | # What would these properties be classified under? 14 | # Need a better name 15 | class SubProps(BaseProps): 16 | @property 17 | def insert_verts(self): 18 | return self.fast_loop_options.insert_verts 19 | 20 | @insert_verts.setter 21 | def insert_verts(self, value): 22 | self.fast_loop_options.insert_verts = value 23 | 24 | @property 25 | def insert_on_selected_edges(self): 26 | return self.fast_loop_options.insert_on_selected_edges 27 | 28 | @insert_on_selected_edges.setter 29 | def insert_on_selected_edges(self, value): 30 | self.fast_loop_options.insert_on_selected_edges = value 31 | 32 | @property 33 | def loop_position_override(self): 34 | return self.fast_loop_options.loop_position_override 35 | 36 | @loop_position_override.setter 37 | def loop_position_override(self, value): 38 | self.fast_loop_options.loop_position_override = value 39 | 40 | 41 | class CommonProps(BaseProps): 42 | @property 43 | def flipped(self): 44 | return self.fast_loop_options.flipped 45 | 46 | @flipped.setter 47 | def flipped(self, value): 48 | self.fast_loop_options.flipped = value 49 | 50 | @property 51 | def use_even(self): 52 | return self.fast_loop_options.use_even 53 | 54 | @use_even.setter 55 | def use_even(self, value): 56 | self.fast_loop_options.use_even = value 57 | 58 | @property 59 | def cancelled(self): 60 | return self.fast_loop_options.cancel 61 | 62 | @cancelled.setter 63 | def cancelled(self, value): 64 | self.fast_loop_options.cancel = value 65 | 66 | @property 67 | def mirrored(self): 68 | return self.fast_loop_options.mirrored 69 | 70 | @mirrored.setter 71 | def mirrored(self, value): 72 | self.fast_loop_options.mirrored = value 73 | 74 | @property 75 | def perpendicular(self): 76 | return self.fast_loop_options.perpendicular 77 | 78 | @perpendicular.setter 79 | def perpendicular(self, value): 80 | self.fast_loop_options.perpendicular = value 81 | 82 | @property 83 | def freeze_edge(self): 84 | return self.fast_loop_options.freeze_edge 85 | 86 | @freeze_edge.setter 87 | def freeze_edge(self, value): 88 | self.fast_loop_options.freeze_edge = value 89 | 90 | @property 91 | def segments(self): 92 | return self.fast_loop_options.segments 93 | 94 | @segments.setter 95 | def segments(self, value): 96 | self.fast_loop_options.segments = value 97 | 98 | class MultiLoopProps(BaseProps): 99 | @property 100 | def scale(self): 101 | return self.fast_loop_options.scale 102 | 103 | @scale.setter 104 | def scale(self, value): 105 | self.fast_loop_options.scale = value 106 | 107 | # Used to display either distance values or scale in the HUD. 108 | @property 109 | def loop_space_value(self): 110 | return self.fast_loop_options.loop_space_value 111 | 112 | @loop_space_value.setter 113 | def loop_space_value(self, value): 114 | self.fast_loop_options.loop_space_value = str(value) 115 | 116 | @property 117 | def use_multi_loop_offset(self): 118 | return self.fast_loop_options.use_multi_loop_offset 119 | 120 | @use_multi_loop_offset.setter 121 | def use_multi_loop_offset(self, value): 122 | self.fast_loop_options.use_multi_loop_offset = value 123 | 124 | class SnapProps(BaseProps): 125 | @property 126 | def use_snap_points(self): 127 | return self.fast_loop_options.use_snap_points 128 | 129 | @use_snap_points.setter 130 | def use_snap_points(self, value): 131 | self.fast_loop_options.use_snap_points = value 132 | 133 | @property 134 | def snap_divisions(self): 135 | return self.fast_loop_options.snap_divisions 136 | 137 | @snap_divisions.setter 138 | def snap_divisions(self, value): 139 | self.fast_loop_options.snap_distance = value 140 | 141 | @property 142 | def lock_snap_points(self): 143 | return self.fast_loop_options.lock_snap_points 144 | 145 | @lock_snap_points.setter 146 | def lock_snap_points(self, value): 147 | self.fast_loop_options.lock_snap_points = value 148 | 149 | @property 150 | def use_distance(self): 151 | return self.fast_loop_options.use_distance 152 | 153 | # @use_distance.setter 154 | # def use_distance(self, value): 155 | # self.use_distance = value 156 | 157 | @property 158 | def auto_segment_count(self): 159 | return self.fast_loop_options.auto_segment_count 160 | 161 | @auto_segment_count.setter 162 | def auto_segment_count(self, value): 163 | self.fast_loop_options.auto_segment_count = value 164 | 165 | @property 166 | def snap_distance(self): 167 | return self.fast_loop_options.snap_distance 168 | 169 | @snap_distance.setter 170 | def snap_distance(self, value): 171 | self.fast_loop_options.snap_distance = value 172 | 173 | @property 174 | def snap_select_vertex(self): 175 | return self._snap_select_vertex 176 | 177 | @snap_select_vertex.setter 178 | def snap_select_vertex(self, value): 179 | self._snap_select_vertex = value 180 | 181 | @property 182 | def auto_segment_count(self): 183 | return self.fast_loop_options.auto_segment_count 184 | 185 | -------------------------------------------------------------------------------- /addon/shaders/icon_minimize_fs.glsl: -------------------------------------------------------------------------------- 1 | 2 | #define PI 3.14159265 3 | 4 | vec2 rotate(vec2 uv, float th) { 5 | return mat2(cos(th), sin(th), -sin(th), cos(th)) * uv; 6 | } 7 | 8 | 9 | float sMin(float a, float b, float k) 10 | { 11 | float h = clamp(0.5+0.5*(b-a)/k, 0.0, 1.0); 12 | return mix(b, a, h) - k*h*(1.0-h); 13 | } 14 | 15 | float sMax(float a, float b, float k) 16 | { 17 | return -sMin(-a, -b, k); 18 | } 19 | 20 | 21 | vec4 getBackgroundColor(vec2 uv) { 22 | uv = uv * 0.5 + 0.5; // remap uv from <-0.5,0.5> to <0.25,0.75> 23 | vec4 gradientStartColor = vec4(1., 0., 1., 1.); 24 | vec4 gradientEndColor = vec4(0., 1., 1., 1.); 25 | return mix(gradientStartColor, gradientEndColor, uv.y); // gradient goes from bottom to top 26 | } 27 | 28 | float sdCircle(vec2 uv, float r, vec2 offset) { 29 | float x = uv.x - offset.x; 30 | float y = uv.y - offset.y; 31 | 32 | return length(vec2(x, y)) - r; 33 | } 34 | 35 | float sdSquare(vec2 uv, float size, vec2 offset) { 36 | float x = uv.x - offset.x; 37 | float y = uv.y - offset.y; 38 | 39 | return max(abs(x), abs(y)) - size; 40 | } 41 | 42 | // Box - exact (https://www.youtube.com/watch?v=62-pRVZuS5c) 43 | float sdBox(vec2 p, vec2 b, vec2 offset) 44 | { 45 | float x = p.x - offset.x; 46 | float y = p.y - offset.y; 47 | p = vec2(x, y); 48 | vec2 d = abs(p)-b; 49 | return length(max(d,0.0)) + min(max(d.x,d.y),0.0); 50 | } 51 | 52 | 53 | // Rounded Box - exact (https://www.shadertoy.com/view/4llXD7 and https://www.youtube.com/watch?v=s5NGeUV2EyU) 54 | float sdRoundedBox( vec2 p, vec2 b, vec4 r ) 55 | { 56 | r.xy = (p.x>0.0)?r.xy : r.zw; 57 | r.x = (p.y>0.0)?r.x : r.y; 58 | vec2 q = abs(p)-b+r.x; 59 | return min(max(q.x,q.y),0.0) + length(max(q,0.0)) - r.x; 60 | } 61 | 62 | // Isosceles Triangle - exact (https://www.shadertoy.com/view/MldcD7) 63 | 64 | float sdTriangleIsosceles( vec2 p, vec2 q, vec2 offset) 65 | { 66 | 67 | float x = p.x - offset.x; 68 | float y = p.y - offset.y; 69 | p = vec2(x, y); 70 | 71 | p.x = abs(p.x); 72 | vec2 a = p - q*clamp( dot(p,q)/dot(q,q), 0.0, 1.0 ); 73 | vec2 b = p - q*vec2( clamp( p.x/q.x, 0.0, 1.0 ), 1.0 ); 74 | float s = -sign( q.y ); 75 | vec2 d = min( vec2( dot(a,a), s*(p.x*q.y-p.y*q.x) ), 76 | vec2( dot(b,b), s*(p.y-q.y) )); 77 | return -sqrt(d.x)*sign(d.y); 78 | } 79 | 80 | // Cut Disk - exact (https://www.shadertoy.com/view/ftVXRc) 81 | float sdCutDisk( vec2 p, float r, float h ) 82 | { 83 | //p = rotate(p, PI); 84 | 85 | float w = sqrt(r*r-h*h); // constant for any given shape 86 | p.x = abs(p.x); 87 | float s = max( (h-r)*p.x*p.x+w*w*(h+r-2.0*p.y), h*p.x-w*p.y ); 88 | return (s<0.0) ? length(p)-r : 89 | (p.x 0.0 ? u_border_radius.x : u_border_radius.y; 120 | float border_radius_right = position.y > 0.0 ? u_border_radius.z : u_border_radius.w; 121 | float border_radius = position.x < 0.0 ? border_radius_left : border_radius_right; 122 | 123 | float y_offset = 0; //u_active == true ? 11. : 4; // 15 for down, 7 for up 124 | float x_offset = 0; 125 | 126 | //float center = sdCutDisk(position, 5., 0.); 127 | 128 | float res; // result 129 | res = sdBox(position, vec2(5 * u_scale, 1 * u_scale), vec2(x_offset, y_offset)); 130 | if (u_active == true) 131 | { 132 | float horizontal_rect = sdBox(position, vec2(1 * u_scale, 5 * u_scale), vec2(x_offset, y_offset)); 133 | res = min(res, horizontal_rect); 134 | } 135 | 136 | // res = min(res, thumb); 137 | // res = min(res, top); 138 | //res = min(res, pinShaft); 139 | //res = min(res, pinPoint); 140 | 141 | float width = .5; 142 | 143 | // res = u_active == true ? res : res; 144 | 145 | // position = gl_FragCoord.xy - u_position.xy - u_size / 2.0; 146 | // vec2 b = half_size; 147 | // vec4 r = vec4(2.); 148 | 149 | // float border = abs(sdRoundedBox(position, b, r)) - width; 150 | // res = min(res, border); 151 | 152 | float _Smoothness = 0.55; 153 | 154 | // vec4 col = Color; 155 | // //col = mix(col, vec4(Color.xyz, 1.), step(0., res)); 156 | // //col = mix(vec4(Color.xyz, 1.), col, smoothstep(-_Smoothness, _Smoothness, pinShaft)); 157 | // fragColor = vec4(vec3(1.), 0.); 158 | 159 | fragColor = Color; // Output to screen 160 | fragColor.a = mix(fragColor.a, 0.0, smoothstep(-_Smoothness, _Smoothness, res)); 161 | 162 | // fragColor.a = mix(fragColor.a, 0.0, step(0., pinShaft)); 163 | 164 | } -------------------------------------------------------------------------------- /addon/shaders/icon_vs.glsl: -------------------------------------------------------------------------------- 1 | 2 | void main() 3 | { 4 | gl_Position = ModelViewProjectionMatrix * vec4(pos.xy, 0.0f, 1.0f); 5 | gl_Position.z = 1.0; 6 | 7 | } -------------------------------------------------------------------------------- /addon/snapping/snap_math.py: -------------------------------------------------------------------------------- 1 | from mathutils import Vector, Matrix 2 | 3 | # All this code is adapted from blender source code. 4 | 5 | # def planes_from_projection_matrix(proj_mat: Matrix): 6 | # """ Returns the near and far plane extracted from the projection matrix. 7 | # taken from Blender source 8 | # """ 9 | # near = Vector().to_4d() 10 | # for i in range(4): 11 | # near[i] = proj_mat[i][3] + proj_mat[i][2] 12 | 13 | # far = Vector().to_4d() 14 | # for i in range(4): 15 | # far[i] = proj_mat[i][3] + proj_mat[i][2] 16 | 17 | # return near, far 18 | 19 | class PreCalculatedData(): 20 | def __init__(self): 21 | self.ray_origin = Vector() 22 | self.ray_direction = Vector() 23 | self.ray_inv_direction = Vector() 24 | self.proj_mat = None 25 | self.mvals = Vector((0.0, 0.0)) 26 | 27 | def snap_bound_box_check_dist(min, max, mvp, win_size, mvals, dist_px_sq, rays): 28 | pre_calc_data = PreCalculatedData() 29 | dist_squared_to_projected_aabb_precalc(pre_calc_data, mvp, win_size, mvals, rays) 30 | dummy = [False, False, False] 31 | bb_dist_sq_px = dist_squared_to_projected_aabb(pre_calc_data, min, max, dummy) 32 | 33 | if bb_dist_sq_px > dist_px_sq: 34 | return False 35 | 36 | return True 37 | 38 | def dist_squared_to_projected_aabb_precalc(data: PreCalculatedData, pmat, win_size, mvals, rays): 39 | 40 | win_half = win_size * 0.5 41 | data.mvals = mvals - win_half 42 | 43 | data.proj_mat = pmat.copy() 44 | 45 | for i in range(4): 46 | 47 | data.proj_mat.col[i][0] *= win_half[0] 48 | data.proj_mat.col[i][1] *= win_half[1] 49 | 50 | data.ray_origin = rays[0] 51 | data.ray_direction = rays[1] 52 | 53 | for i in range(3): 54 | data.ray_inv_direction[i] = 1/data.ray_direction[i] if data.ray_direction[i] != 0 else float('INF') 55 | 56 | def aabb_get_near_far_from_plane(plane_no, bbmin, bbmax): 57 | 58 | bb_near = Vector((0.0, 0.0, 0.0)) 59 | bb_afar = Vector((0.0, 0.0, 0.0)) 60 | 61 | if plane_no[0] < 0.0: 62 | bb_near[0] = bbmax[0] 63 | bb_afar[0] = bbmin[0] 64 | 65 | else: 66 | bb_near[0] = bbmin[0] 67 | bb_afar[0] = bbmax[0] 68 | 69 | if plane_no[1] < 0.0: 70 | bb_near[1] = bbmax[1] 71 | bb_afar[1] = bbmin[1] 72 | 73 | else: 74 | bb_near[1] = bbmin[1] 75 | bb_afar[1] = bbmax[1] 76 | 77 | if plane_no[2] < 0.0: 78 | bb_near[2] = bbmax[2] 79 | bb_afar[2] = bbmin[2] 80 | 81 | else: 82 | bb_near[2] = bbmin[2] 83 | bb_afar[2] = bbmax[2] 84 | 85 | return bb_near, bb_afar 86 | 87 | def dist_squared_to_projected_aabb(data: PreCalculatedData, bbmin, bbmax, r_axis_closest): 88 | 89 | local_bvmin, local_bvmax = aabb_get_near_far_from_plane(data.ray_direction, bbmin, bbmax) 90 | 91 | tmin = Vector() 92 | tmin[0] = (local_bvmin[0] - data.ray_origin[0]) * data.ray_inv_direction[0] 93 | tmin[1] = (local_bvmin[1] - data.ray_origin[1]) * data.ray_inv_direction[1] 94 | tmin[2] = (local_bvmin[2] - data.ray_origin[2]) * data.ray_inv_direction[2] 95 | 96 | tmax = Vector() 97 | tmax[0] = (local_bvmax[0] - data.ray_origin[0]) * data.ray_inv_direction[0] 98 | tmax[1] = (local_bvmax[1] - data.ray_origin[1]) * data.ray_inv_direction[1] 99 | tmax[2] = (local_bvmax[2] - data.ray_origin[2]) * data.ray_inv_direction[2] 100 | 101 | va = vb = Vector() 102 | rtmin = rtmax = 0.0 103 | main_axis = 0 104 | 105 | r_axis_closest[0] = r_axis_closest[1] = r_axis_closest[2] = False 106 | 107 | if tmax[0] <= tmax[1] and tmax[0] <= tmax[2]: 108 | rtmax = tmax[0] 109 | va[0] = vb[0] = local_bvmax[0] 110 | main_axis = 3 111 | r_axis_closest[0] = data.ray_direction[0] < 0.0 112 | 113 | elif tmax[1] <= tmax[0] and tmax[1] <= tmax[2]: 114 | rtmax = tmax[1] 115 | va[1] = vb[1] = local_bvmax[1] 116 | main_axis = 2 117 | r_axis_closest[1] = data.ray_direction[1] < 0.0 118 | else: 119 | rtmax = tmax[2] 120 | va[2] = vb[2] = local_bvmax[2] 121 | main_axis = 1 122 | r_axis_closest[2] = data.ray_direction[2] < 0.0 123 | 124 | if tmin[0] >= tmin[1] and tmin[0] >= tmin[2]: 125 | rtmin = tmin[0] 126 | va[0] = vb[0] = local_bvmin[0] 127 | main_axis -= 3 128 | r_axis_closest[0] = data.ray_direction[0] >= 0.0 129 | 130 | elif tmin[1] >= tmin[0] and tmin[1] >= tmin[2]: 131 | rtmin = tmin[1] 132 | va[1] = vb[1] = local_bvmin[1] 133 | main_axis -= 1 134 | r_axis_closest[1] = data.ray_direction[1] >= 0.0 135 | else: 136 | rtmin = tmin[2] 137 | va[2] = vb[2] = local_bvmin[2] 138 | main_axis -= 2 139 | r_axis_closest[2] = data.ray_direction[2] >= 0.0 140 | 141 | if rtmin <= rtmax: 142 | return 0 143 | 144 | if data.ray_direction[main_axis] >= 0.0: 145 | va[main_axis] = local_bvmin[main_axis] 146 | vb[main_axis] = local_bvmax[main_axis] 147 | else: 148 | va[main_axis] = local_bvmax[main_axis] 149 | vb[main_axis] = local_bvmin[main_axis] 150 | 151 | scale = abs(local_bvmax[main_axis] - local_bvmin[main_axis]) 152 | 153 | va2d = Vector(( 154 | (data.proj_mat.col[0].xyz.dot(va) + data.proj_mat.col[0][3]), 155 | (data.proj_mat.col[1].xyz.dot(va) + data.proj_mat.col[1][3]), 156 | )) 157 | 158 | vb2d = Vector(( 159 | (va2d[0] + data.proj_mat.col[0][main_axis] * scale), 160 | (va2d[1] + data.proj_mat.col[1][main_axis] * scale), 161 | )) 162 | 163 | w_a = data.proj_mat.col[3].xyz.dot(va) + data.proj_mat[3][3] 164 | if w_a != 1.0: 165 | w_b = w_a + data.proj_mat.col[3][main_axis] * scale 166 | 167 | va2d /= w_a 168 | vb2d /= w_b 169 | 170 | rdist_sq = 0.0 171 | 172 | dvec = data.mvals - va2d 173 | edge = vb2d - va2d 174 | lambda_ = dvec.dot(edge) 175 | 176 | if lambda_ != 0.0: 177 | lambda_ /= edge.length_squared 178 | if lambda_ <= 0.0: 179 | rdist_sq = (data.mvals - va2d).length_squared 180 | r_axis_closest[main_axis] = True 181 | elif lambda_ >= 1.0: 182 | rdist_sq = (data.mvals - vb2d).length_squared 183 | r_axis_closest[main_axis] = False 184 | else: 185 | va2d = edge * lambda_ 186 | rdist_sq = (data.mvals - va2d).length_squared 187 | r_axis_closest[main_axis] = lambda_ < 0.5 188 | else: 189 | rdist_sq = (data.mvals - va2d).length_squared 190 | 191 | return rdist_sq 192 | 193 | 194 | def isect_plane_plane_v3(plane_a, plane_b): 195 | isect_co = Vector() 196 | isect_no = Vector() 197 | det = 0 198 | plane_c = plane_a.xyz.cross(plane_b.xyz) 199 | 200 | det = plane_c.length_squared 201 | 202 | if det != 0.0: 203 | isect_co = (plane_c.xyz.cross(plane_b.xyz)) * plane_a[3] 204 | 205 | isect_co += (plane_a.xyz.cross(plane_c.xyz)) * plane_b[3] 206 | 207 | isect_co *= 1/det 208 | isect_no = plane_c.xyz 209 | 210 | return isect_co, isect_no 211 | 212 | return None 213 | 214 | def isect_ray_line_v3(v0, v1, ray_direction, ray_origin): 215 | 216 | a = v1 - v0 217 | t = v0 - ray_origin 218 | n = a.cross(ray_direction) 219 | nlen = n.length_squared 220 | 221 | # if (nlen == 0.0f) the lines are parallel, has no nearest point, only distance squared.*/ 222 | if nlen == 0.0: 223 | return False, 0.0 224 | 225 | else: 226 | c = n - t 227 | cray = c.cross(ray_direction) 228 | 229 | return True, cray.dot(n) / nlen -------------------------------------------------------------------------------- /addon/snapping/snapping_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING, Tuple 5 | 6 | if TYPE_CHECKING: 7 | from .snapping import SnapObjectEditMeshData 8 | 9 | from bmesh.types import * 10 | from mathutils import Vector, Matrix 11 | from mathutils.geometry import intersect_point_line 12 | 13 | from . import snap_math 14 | 15 | # This isn't what I'd consider utilities 16 | 17 | @dataclass 18 | class SnapEdgeParams(): 19 | snap_object = None 20 | edge_index: int = None 21 | 22 | ray_origin: Vector = None 23 | ray_direction: Vector = None 24 | radius: float = None 25 | win_size: float = None 26 | mval: Vector = Vector((0.0, 0.0)) 27 | 28 | proj_matrix: Matrix = None 29 | is_perpective: bool = False 30 | 31 | def do_raycast(snap_object: SnapObjectEditMeshData, origin: Vector, direction: Vector)-> None | Tuple: 32 | while True: 33 | isect_co, _, face_index, distance = snap_object.bvh_tree.ray_cast( 34 | origin, direction) 35 | bm = snap_object.bm 36 | if face_index is None: 37 | return None 38 | 39 | try: 40 | face = bm.faces[face_index] 41 | except IndexError: 42 | bm.faces.ensure_lookup_table() 43 | continue 44 | 45 | if face is not None and not face.hide: 46 | return isect_co, face, distance 47 | elif face is not None and face.hide: 48 | origin = isect_co + direction*0.0001 49 | 50 | 51 | def cb_snap_edge(params: SnapEdgeParams)-> None | Tuple: 52 | current_object = params.snap_object 53 | bm = current_object.bm 54 | bm.edges.ensure_lookup_table() 55 | edge: BMEdge = bm.edges[params.edge_index] 56 | va_co = edge.verts[0].co 57 | vb_co = edge.other_vert(edge.verts[0]).co 58 | 59 | nearest_co = _test_projected_edge_dist(params, (va_co, vb_co)) 60 | if nearest_co is not None: 61 | _, perc = intersect_point_line(nearest_co, va_co, vb_co) 62 | if perc < 0.0: 63 | return va_co, 0.0 64 | elif perc > 1.0: 65 | return vb_co, 1.0 66 | elif 0.0 <= perc <= 1.0: 67 | return va_co.lerp(vb_co, perc), perc 68 | 69 | return None 70 | 71 | 72 | def _test_projected_edge_dist(params, verts_co)-> None | Vector: 73 | va_co, vb_co = verts_co 74 | intersects, lambda_ = snap_math.isect_ray_line_v3( 75 | va_co, vb_co, params.ray_direction, params.ray_origin) 76 | near_co = Vector() 77 | 78 | if not intersects: 79 | near_co = va_co.copy() 80 | else: 81 | if lambda_ <= 0.0: 82 | near_co = va_co.copy() 83 | elif lambda_ >= 1.0: 84 | near_co = vb_co.copy() 85 | else: 86 | near_co = va_co.lerp(vb_co, lambda_) 87 | 88 | if _test_projected_vert_dist(params, near_co): 89 | return near_co 90 | return None 91 | 92 | 93 | def _test_projected_vert_dist(params, co)-> bool: 94 | 95 | win_half = params.win_size * 0.5 96 | mvals = params.mval - win_half 97 | current_object = params.snap_object 98 | co = current_object.object_matrix @ co 99 | 100 | proj_mat = params.proj_matrix 101 | 102 | for i in range(4): 103 | proj_mat.col[i][0] *= win_half[0] 104 | proj_mat.col[i][1] *= win_half[1] 105 | 106 | pro_mat = proj_mat.to_3x3() 107 | 108 | row_x = pro_mat.row[0] 109 | row_y = pro_mat.row[1] 110 | 111 | co_2d = Vector(( 112 | row_x.dot(co) + proj_mat.col[3][0], 113 | row_y.dot(co) + proj_mat.col[3][1] 114 | )) 115 | 116 | if params.is_perpective: 117 | w = (proj_mat.col[0][3] * co[0]) + (proj_mat.col[1][3] * co[1]) + (proj_mat.col[2][3] * co[2]) + proj_mat.col[3][3] 118 | co_2d /= w 119 | dist_sq = (mvals - co_2d).length 120 | 121 | dist_px_sq = params.radius 122 | if dist_sq < dist_px_sq: 123 | return True 124 | return False -------------------------------------------------------------------------------- /addon/tools/__init__.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from . import fast_loop 3 | 4 | tools = { 5 | fast_loop.FL_FastLoop: ("builtin.poly_build", False, False), 6 | } 7 | 8 | def register(): 9 | for tool, (after, separated, grouped) in tools.items(): 10 | 11 | bpy.utils.register_tool(tool, after=after, separator=separated, group=grouped) 12 | 13 | def unregister(): 14 | for tool in reversed(list(tools.keys())): 15 | bpy.utils.unregister_tool(tool) 16 | -------------------------------------------------------------------------------- /addon/tools/fast_loop.py: -------------------------------------------------------------------------------- 1 | import os 2 | import bpy 3 | 4 | from ..utils.common import prefs 5 | from .. ui.ui import DrawFastLoopUI 6 | 7 | class FL_ToolBase(bpy.types.WorkSpaceTool): 8 | bl_space_type='VIEW_3D' 9 | bl_context_mode='EDIT_MESH' 10 | 11 | def get_operator(): 12 | raise NotImplementedError 13 | 14 | @classmethod 15 | def draw_settings( cls ,context, layout, tool): 16 | region_type = context.region.type 17 | 18 | if region_type == 'UI' : 19 | cls.draw_settings_ui(context, layout) 20 | elif region_type == 'WINDOW' : 21 | cls.draw_settings_ui(context, layout) 22 | elif region_type == 'TOOL_HEADER' : 23 | cls.draw_settings_toolheader(context, layout, tool) 24 | 25 | 26 | @classmethod 27 | def draw_settings_toolheader(cls, context, layout, tool): 28 | pass 29 | 30 | 31 | @classmethod 32 | def draw_settings_ui(cls, context, layout): 33 | pass 34 | 35 | 36 | class FL_FastLoop(FL_ToolBase, DrawFastLoopUI): 37 | bl_idname = "fl.fast_loop_tool" 38 | bl_label = "Fast Loop" 39 | bl_description = ( "Add loop cuts or modify existing ones" ) 40 | bl_icon = os.path.join(os.path.join(os.path.dirname(__file__), "icons") , "fl.fast_loop") 41 | bl_keymap = (("exe.fast_loop",{"type": 'MOUSEMOVE', "value": 'ANY' },{"properties": []}),) 42 | 43 | 44 | @classmethod 45 | def draw_settings_toolheader(cls, context, layout, tool): 46 | 47 | if prefs().get_edge_flow_version() is not None: 48 | popover_kw = {"space_type": 'VIEW_3D', "region_type": 'UI', "category": "Tool"} 49 | layout.popover_group(context=".set_flow_options", **popover_kw) 50 | 51 | popover_kw = {"space_type": 'VIEW_3D', "region_type": 'UI', "category": "Tool"} 52 | layout.popover_group(context=".hud_settings", **popover_kw) 53 | 54 | 55 | @classmethod 56 | def draw_settings_ui(cls, context, layout): 57 | cls.draw_fastloop_ui(context, layout) 58 | 59 | 60 | def get_operator(): 61 | return 'fl.fast_loop' -------------------------------------------------------------------------------- /addon/tools/icons/fl.fast_loop.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jrome90/Fast-Loop/e874d714f462eb2bbce1bd1bd6e1242820cf3b09/addon/tools/icons/fl.fast_loop.dat -------------------------------------------------------------------------------- /addon/tools/icons/fl.fast_loop_classic.dat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jrome90/Fast-Loop/e874d714f462eb2bbce1bd1bd6e1242820cf3b09/addon/tools/icons/fl.fast_loop_classic.dat -------------------------------------------------------------------------------- /addon/ui/__init__.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from . import panels 3 | from . import ui 4 | 5 | classes = ( 6 | panels.VIEW3D_PT_FastLoopSetFlowOptions, 7 | panels.VIEW3D_PT_HUDSettings, 8 | ui.FL_UL_Percentages, 9 | ) 10 | 11 | 12 | def register(): 13 | for cls in classes: 14 | bpy.utils.register_class(cls) 15 | 16 | 17 | def unregister(): 18 | for cls in reversed(classes): 19 | bpy.utils.unregister_class(cls) 20 | -------------------------------------------------------------------------------- /addon/ui/gizmos/__init__.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from . import gizmo_snapping 3 | 4 | classes = [ 5 | gizmo_snapping.RP_GGT_SnapGizmoGroup, 6 | ] 7 | 8 | def register(): 9 | for cls in classes: 10 | bpy.utils.register_class(cls) 11 | 12 | def unregister(): 13 | for cls in reversed(classes): 14 | bpy.utils.unregister_class(cls) -------------------------------------------------------------------------------- /addon/ui/gizmos/gizmo_snapping.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from ....signalslot.signalslot import Signal 3 | 4 | import bpy 5 | from bpy.types import GizmoGroup 6 | 7 | from ... import utils 8 | 9 | def get_props(): 10 | return utils.ops.fl_props() 11 | 12 | 13 | class RP_GGT_SnapGizmoGroup(GizmoGroup): 14 | 15 | bl_idname = "fl.snap_gizmo_group" 16 | bl_label = "Edit pivot GG" 17 | bl_space_type = 'VIEW_3D' 18 | bl_region_type = 'WINDOW' 19 | bl_options = {'3D'} 20 | 21 | on_snap_update = Signal(args=['[snap_location]']) 22 | 23 | def __init__(self) -> None: 24 | super().__init__() 25 | 26 | self.snap_gizmo = None 27 | 28 | 29 | @classmethod 30 | def poll(cls, context): 31 | return True 32 | 33 | 34 | def setup(self, context): 35 | self.snap_gizmo = self.gizmos.new("GIZMO_GT_snap_3d") 36 | 37 | 38 | 39 | def draw_prepare(self, context): 40 | if self.snap_gizmo is not None: 41 | index, elem_type = get_element_type_and_index(self.snap_gizmo.snap_elem_index) 42 | snap_location = self.snap_gizmo.location 43 | if snap_location is not None and elem_type is not None: 44 | self.on_snap_update.emit(snap_location=snap_location) 45 | 46 | 47 | def is_snap_data_changed(context, element_type, element_index)-> bool: 48 | window_manager = context.window_manager 49 | shared_snap_data = window_manager.Shared_Snap_Data 50 | 51 | if shared_snap_data.element_type != "NONE" or element_type != "NONE": 52 | return True 53 | return False 54 | 55 | 56 | def get_element_type_and_index(elem_type): 57 | if all(e == 0 for e in elem_type): 58 | return -1, "NONE" 59 | 60 | if elem_type[0] != -1: 61 | return elem_type[0], "VERTEX" 62 | elif elem_type[1] != -1: 63 | return elem_type[1], "EDGE" 64 | elif elem_type[2] != -1: 65 | return elem_type[2], "FACE" 66 | 67 | return -1, "NONE" 68 | 69 | 70 | # def get_active_snap_elements(context): 71 | # snap_elements: set = context.tool_settings.snap_elements 72 | # invalid: set = {"INCREMENT", "EDGE", "VOLUME", "EDGE_PERPENDICULAR"} 73 | 74 | # snap_elements.difference_update(invalid) 75 | 76 | # return snap_elements -------------------------------------------------------------------------------- /addon/ui/panels.py: -------------------------------------------------------------------------------- 1 | from bpy.types import Panel 2 | 3 | from .. import utils 4 | from . ui import (DrawFastLoopUI, DrawLoopSliceUI) 5 | 6 | class VIEW3D_PT_FastLoopToolPanel(Panel): 7 | bl_space_type = 'VIEW_3D' 8 | bl_region_type = 'UI' 9 | bl_category = 'Edit' 10 | bl_context = "mesh_edit" 11 | bl_label = "Fast Loop" 12 | bl_options = {'DEFAULT_CLOSED'} 13 | 14 | def draw(self, context): 15 | DrawFastLoopUI.draw_fastloop_ui(context, self.layout) 16 | 17 | 18 | class VIEW3D_PT_FastLoopSetFlowOptions(Panel): 19 | bl_space_type = 'VIEW_3D' 20 | bl_region_type = 'UI' 21 | bl_category = "Tool" 22 | bl_context = ".set_flow_options" 23 | bl_label = "Set Flow Options" 24 | bl_options = {'DEFAULT_CLOSED'} 25 | 26 | def draw(self, context): 27 | layout = self.layout 28 | layout.ui_units_x = 7.0 29 | preferences = utils.common.prefs() 30 | col = layout.column() 31 | col.prop(preferences, "set_edge_flow_enabled" , text = "Set Flow" , expand = True, toggle=True ) 32 | 33 | box = col.box() 34 | 35 | row = box.row() 36 | row.prop(preferences, "tension", text= "Tension", expand = True) 37 | row = box.row() 38 | row.prop(preferences, "iterations", text= "Iterations", expand = True) 39 | row = box.row() 40 | row.prop(preferences, "min_angle", text= "Min Angle", expand = True) 41 | 42 | 43 | class VIEW3D_PT_HUDSettings(Panel): 44 | bl_space_type = 'VIEW_3D' 45 | bl_region_type = 'UI' 46 | 47 | bl_category = "Tool" 48 | bl_context = ".hud_settings" 49 | bl_label = "HUD Settings" 50 | bl_options = {'DEFAULT_CLOSED'} 51 | 52 | def draw(self, context): 53 | prefs = utils.common.prefs() 54 | layout = self.layout 55 | 56 | layout.ui_units_x = 5.0 57 | layout.label(text="Segment Bar") 58 | 59 | layout_split = layout.split() 60 | b = layout_split.box() 61 | col = b.column() 62 | col.prop(prefs, "show_bar", text="Show" if not utils.common.prefs().show_bar else "Hide", toggle=True) 63 | col.prop(prefs, "show_percents", text="Show Percents" if not utils.common.prefs().show_percents else "Hide Percents", toggle=True) 64 | 65 | layout.label(text="Display Units") 66 | layout_split = layout.split() 67 | b = layout_split.box() 68 | col = b.column() 69 | units_to_display = utils.ui.get_units_to_display(True) 70 | for unit in units_to_display: 71 | col.prop(prefs, unit, toggle=True) 72 | 73 | layout.label(text="Distance Text") 74 | layout_split = layout.split() 75 | b = layout_split.box() 76 | col = b.column() 77 | col.prop(prefs, "draw_distance_segment", text="Show" if not utils.common.prefs().draw_distance_segment else "Hide", toggle=True) 78 | 79 | 80 | class VIEW3D_PT_LoopSlicePanel(Panel): 81 | bl_space_type = 'VIEW_3D' 82 | bl_region_type = 'UI' 83 | bl_category = 'Edit' 84 | bl_context = "mesh_edit" 85 | bl_label = "Loop Slice" 86 | bl_options = {'DEFAULT_CLOSED'} 87 | 88 | def draw(self, context): 89 | DrawLoopSliceUI.draw_loopslice_ui(context, self.layout) -------------------------------------------------------------------------------- /addon/ui/ui.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from .. import utils 4 | 5 | 6 | class FL_UL_Percentages(bpy.types.UIList): 7 | def __init__(self): 8 | super().__init__() 9 | self.use_filter_show = False 10 | 11 | 12 | def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): 13 | if self.layout_type in {'DEFAULT', 'COMPACT'}: 14 | col = layout.column() 15 | col.prop(item, item.get_method().lower(), text=f"{index}", emboss=False) 16 | 17 | elif self.layout_type in {'GRID'}: 18 | pass 19 | 20 | 21 | 22 | class DrawFastLoopUI(): 23 | 24 | @classmethod 25 | def draw_fastloop_ui(cls, context, layout): 26 | 27 | options = utils.ops.options() 28 | if options is not None: 29 | 30 | box = layout.split() 31 | b = box.box() 32 | col = b.column() 33 | col.prop(options, "loop_position_override" , toggle=True, text="Position Override", icon='SHADERFX') 34 | 35 | pos_override_col = b.column() 36 | pos_override_col.enabled = True if options.loop_position_override else False 37 | prefs = utils.common.prefs() 38 | pos_override_col.prop(prefs, "interpolation_type") 39 | 40 | pos_override_col.operator("ui.overrride_reset", text="Reset", icon='FILE_REFRESH') 41 | pos_override_col.label(text="Loop Postitions") 42 | 43 | window_manager = context.window_manager 44 | if len(window_manager.Loop_Cut_Slots.loop_cut_slots) > 0: 45 | index = window_manager.Loop_Cut_Lookup_Index 46 | lc_slot = window_manager.Loop_Cut_Slots.loop_cut_slots[index] 47 | pos_override_col.template_list("FL_UL_Percentages", "", lc_slot, "loop_cut_slot", window_manager, "Loop_Cut_Slots_Index") 48 | 49 | box = layout.split() 50 | 51 | b = box.box() 52 | col = b.column(align=True) 53 | 54 | col.label(text="Snapping") 55 | 56 | if options is not None: 57 | col.prop(options, "use_snap_points", toggle=True, text="Toggle Snap Points", icon='SNAP_INCREMENT') 58 | col.prop(options, "lock_snap_points", toggle=True, text="Unlock Snap Points" if options.lock_snap_points else "Lock Points", icon='LOCKED' if options.lock_snap_points else 'UNLOCKED') 59 | col.prop(options, "snap_divisions", slider=True) 60 | 61 | col.prop(options, "use_distance") 62 | use_dist_col = b.column() 63 | use_dist_col.enabled = True if options.use_distance else False 64 | use_dist_col.prop(options, "auto_segment_count") 65 | use_dist_col.prop(options, "snap_distance") 66 | use_dist_col.prop(options, "use_opposite_snap_dist", toggle=True) 67 | use_dist_col.prop(options, "major_tick_mult") 68 | 69 | col = b.row(align=True) 70 | col.prop(options, "snap_left", toggle=True, text="Left") 71 | col.prop(options, "snap_center", toggle=True, text="Center") 72 | col.prop(options, "snap_right", toggle=True, text="Right") 73 | 74 | box = layout.split() 75 | b = box.box() 76 | col = b.column() 77 | unit_settings = bpy.context.scene.unit_settings 78 | unit_system = unit_settings.system 79 | col.label(text="Numeric Input Default Unit") 80 | if unit_system in {'METRIC'}: 81 | col.prop(prefs, "metric_unit_default") 82 | else: 83 | col.prop(prefs, "imperial_unit_default") 84 | 85 | 86 | class DrawLoopSliceUI(): 87 | 88 | @classmethod 89 | def draw_loopslice_ui(cls, context, layout): 90 | 91 | options = utils.ops.ls_options() 92 | if options is not None: 93 | 94 | box = layout 95 | b = box.box() 96 | col = b.column() 97 | 98 | col.prop(options, "edit_mode") 99 | col.prop(options, "mode") 100 | 101 | b = box.box() 102 | col = b.column() 103 | 104 | col.prop(options, "active_index", text="Current") 105 | col.prop(options, "slice_count", text="Count") 106 | 107 | b = box.box() 108 | col = b.column() 109 | col.prop(options, "use_split", text="Split") 110 | 111 | split_col = b.column() 112 | split_col.enabled = True if options.use_split else False 113 | split_col.prop(options, "cap_sections", text="Cap Sections") 114 | split_col.prop(options, "gap_distance", text="Gap") 115 | 116 | b = box.box() 117 | col = b.column() 118 | col.label(text="Slider") 119 | col.prop(options, "active_position", text="Position") -------------------------------------------------------------------------------- /addon/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from . import common 2 | from . import ops 3 | from . import ui 4 | from . import safety 5 | from . import math 6 | from . import mesh 7 | from . import edge_slide 8 | from . import draw_3d 9 | from . import draw_2d 10 | from . import raycast 11 | from . import serialization 12 | from . import shaders -------------------------------------------------------------------------------- /addon/utils/common.py: -------------------------------------------------------------------------------- 1 | from ... import __package__ as base_package 2 | 3 | import bpy 4 | 5 | 6 | def prefs(): 7 | return bpy.context.preferences.addons[base_package].preferences 8 | 9 | def set_addon_preference(option, value)-> bool: 10 | if hasattr(prefs(), option): 11 | setattr(prefs(), option, value) 12 | return True 13 | return False 14 | 15 | def get_blender_version()-> tuple: 16 | return bpy.app.version 17 | 18 | def min_ver_4_2()-> bool: 19 | version = get_blender_version() 20 | major = version[0] 21 | minor = version[1] 22 | return (major == 4 and minor >= 2) or (major > 4) 23 | 24 | def is_modal_running(operator: str): 25 | op = bpy.context.window.modal_operators.get(operator, None) 26 | return op is not None 27 | -------------------------------------------------------------------------------- /addon/utils/draw_2d.py: -------------------------------------------------------------------------------- 1 | import blf 2 | import gpu 3 | 4 | from gpu import state 5 | from gpu_extras.batch import batch_for_shader 6 | from gpu_extras import presets 7 | from mathutils import Vector, Color 8 | 9 | from struct import pack 10 | 11 | from . ui import get_ui_scale, get_headers_height 12 | 13 | def batch_from_points(points, type_, shader): 14 | return batch_for_shader(shader, type_, {"pos": points}) 15 | 16 | def draw_points(points, color=(.0, 1.0, 0.0, 1), size=3.0): 17 | ui_scale = get_ui_scale() 18 | state.point_size_set(size * ui_scale) 19 | 20 | shader = gpu.shader.from_builtin('UNIFORM_COLOR') 21 | batch = batch_for_shader(shader, 'POINTS', {"pos": points}) 22 | shader.bind() 23 | shader.uniform_float("color", color) 24 | 25 | batch.draw(shader) 26 | 27 | def draw_line(points, line_color=(0.0, 1.0, 0.5, 0.4), line_width=1.0): 28 | ui_scale = get_ui_scale() 29 | state.line_width_set(line_width * ui_scale) 30 | 31 | state.blend_set('ALPHA') 32 | 33 | # bgl.glEnable(bgl.GL_LINE_SMOOTH) 34 | 35 | shader = gpu.shader.from_builtin('UNIFORM_COLOR') 36 | batch = batch_from_points(points, 'LINE_STRIP', shader) 37 | 38 | shader.bind() 39 | color = shader.uniform_from_name("color") 40 | r, g, b, a = line_color 41 | shader.uniform_vector_float(color, pack("4f", r, g, b, a), 4) 42 | batch.draw(shader) 43 | 44 | 45 | def draw_line_smooth(points, line_colors, line_width): 46 | #POLYLINE_SMOOTH_COLOR 47 | 48 | ui_scale = get_ui_scale() 49 | state.line_width_set(line_width * ui_scale) 50 | 51 | state.blend_set('ALPHA') 52 | 53 | # bgl.glEnable(bgl.GL_LINE_SMOOTH) 54 | 55 | shader = gpu.shader.from_builtin('POLYLINE_SMOOTH_COLOR') 56 | batch = batch_for_shader(shader, 'LINE_STRIP', {"pos": points, "color": line_colors}) 57 | 58 | shader.bind() 59 | # color = shader.uniform_from_name("color") 60 | # r, g, b, a = line_color 61 | # shader.uniform_vector_float(color, pack("4f", r, g, b, a), 4) 62 | batch.draw(shader) 63 | 64 | def draw_rectangle(size=10, color=(1.0, 1.0, 1.0, 1.0), center=(0,0)): 65 | size *= get_ui_scale() 66 | bottom_left = Vector(center) + Vector((-1,-1)) * size 67 | bottom_right = Vector(center) + Vector((1,-1)) * size 68 | top_right = Vector(center) + Vector((1,1)) * size 69 | top_left = Vector(center) + Vector((-1,1)) * size 70 | vertices = (top_left, top_right, bottom_right, bottom_left) 71 | 72 | 73 | shader = gpu.shader.from_builtin('UNIFORM_COLOR') 74 | batch = batch_for_shader(shader, 'LINE_LOOP', {"pos": vertices}) 75 | 76 | 77 | shader.bind() 78 | shader.uniform_float("color", color) 79 | batch.draw(shader) 80 | 81 | def draw_circle(center, radius, color: Color = (1,1,1,1)): 82 | ui_scale = get_ui_scale() 83 | presets.draw_circle_2d(center, color, radius * ui_scale) 84 | 85 | 86 | def draw_line_strip(coords, line_width=2): 87 | 88 | shader = gpu.shader.from_builtin('UNIFORM_COLOR') 89 | batch = batch_for_shader(shader, 'LINE_STRIP', {"pos": coords}) 90 | ui_scale = get_ui_scale() 91 | state.line_width_set(line_width * ui_scale) 92 | 93 | shader.bind() 94 | shader.uniform_float("color", (1, 1, 1, 1.0)) 95 | batch.draw(shader) 96 | 97 | state.line_width_set(1) 98 | state.blend_set('NONE') 99 | 100 | 101 | def draw_region_border(context, line_color=(1, 1, 1, 1), width=2, text="Selection"): 102 | region = context.region 103 | scale = get_ui_scale() 104 | width *= scale 105 | header_height = get_headers_height() 106 | top_left = Vector((width, region.height - (width + header_height))) 107 | top_right = Vector((region.width - width, region.height - (width + header_height))) 108 | bottom_right = Vector((region.width - width, width)) 109 | bottom_left = Vector((width, width)) 110 | vertices = (top_left, top_right, bottom_right, bottom_left,) 111 | 112 | state.line_width_set(width) 113 | 114 | shader = gpu.shader.from_builtin('UNIFORM_COLOR') 115 | shader.bind() 116 | 117 | shader.uniform_float("color", line_color) 118 | 119 | batch = batch_for_shader(shader, 'LINE_LOOP', {"pos": vertices}) 120 | batch.draw(shader) 121 | 122 | if text: 123 | 124 | font_size = int(16 * scale) 125 | 126 | blf.size(0, font_size) 127 | blf.color(0, *line_color) 128 | 129 | center = (region.width) / 2 - 20 130 | blf.position(0, center - int(60 * scale), region.height - header_height - int(font_size) - 5, 0) 131 | 132 | blf.draw(0, text) 133 | 134 | def draw_text_on_screen(text, position, font_size, text_dim=None, text_color=(1.0, 1.0, 1.0, 1.0), h_alignment: set=None, v_alignment: set=None, rotation=None): 135 | if position is None: 136 | return 137 | 138 | scale = get_ui_scale() 139 | font_size = int(font_size * scale) 140 | 141 | blf.color(0, *text_color) 142 | blf.size(0, font_size) 143 | # if not (h_aligned_center and not v_aligned_center): 144 | # blf.position(0, position[0], position[1], 0) 145 | # else: 146 | h_pos = position[0] 147 | v_pos = position[1] 148 | 149 | if text_dim is None: 150 | width, height = blf.dimensions(0, text) 151 | else: 152 | # This is dumb. But it's annoying when the height of the text changes the position when v_align is TOP 153 | width, height = text_dim 154 | if width is None: 155 | width, _ = blf.dimensions(0, text) 156 | height *= scale 157 | 158 | 159 | half_width = width * 0.5 160 | half_height = height * 0.5 161 | if h_alignment: 162 | if 'CENTER' in h_alignment: 163 | h_pos = position[0] - half_width 164 | 165 | if v_alignment: 166 | if 'CENTER' in v_alignment: 167 | v_pos = position[1] - half_height 168 | elif 'TOP' in v_alignment: 169 | v_pos = position[1] - (2 * height) 170 | elif 'BOTTOM' in v_alignment: 171 | v_pos = v_pos = position[1] + (2 * height) 172 | 173 | # h_aligned_center = False if 'CENTER' not in h_alignment else True 174 | # v_aligned_center = False if 'CENTER' not in v_alignment else True 175 | # v_aligned_top = False if 'TOP' not in v_alignment else True 176 | 177 | 178 | # h_pos = position[0] - half_width if h_aligned_center else position[0] 179 | # v_pos = position[1] - half_height if v_aligned_center else position[1] 180 | 181 | blf.position(0, h_pos, v_pos, 0) 182 | # if rotation is not None: 183 | # blf.enable(0, blf.ROTATION) 184 | # blf.rotation(0, rotation) 185 | # else: 186 | # blf.disable(0, blf.ROTATION) 187 | 188 | blf.draw(0, text) 189 | 190 | # if rotation is not None: 191 | # blf.disable(0, blf.ROTATION) 192 | # blf.rotation(0, 0) 193 | 194 | 195 | 196 | def draw_debug_text_border(position:Vector, font_size, line_color=(1, 1, 1, 1), text="Selection"): 197 | 198 | scale = get_ui_scale() 199 | #font_size = int(font_size * scale) 200 | height = int(font_size * scale) 201 | width = font_size * len(text) * (0.60) 202 | 203 | width *= scale 204 | top_left = position + Vector((0, height)) 205 | top_right = position + Vector((width, height)) 206 | bottom_right = position + Vector((width, -height* 0.25)) 207 | bottom_left = position + Vector((0, -height* 0.25)) 208 | 209 | 210 | vertices = (top_left, top_right, bottom_right, bottom_left,) 211 | 212 | state.line_width_set(1) 213 | 214 | shader = gpu.shader.from_builtin('UNIFORM_COLOR') 215 | shader.bind() 216 | 217 | shader.uniform_float("color", line_color) 218 | 219 | batch = batch_for_shader(shader, 'LINE_LOOP', {"pos": vertices}) 220 | batch.draw(shader) -------------------------------------------------------------------------------- /addon/utils/math.py: -------------------------------------------------------------------------------- 1 | from math import isclose 2 | 3 | import bpy 4 | 5 | from bpy_extras.view3d_utils import location_3d_to_region_2d 6 | from mathutils import Vector, Matrix 7 | from mathutils.geometry import intersect_point_line 8 | 9 | 10 | def is_point_on_line_segment(point, vec_a, vec_b, abs_tol=1e-6): 11 | dist, lambda_ = dist_to_line_segment_squared(point, vec_a, vec_b) 12 | if 0.0 <= lambda_ <= 1.0: 13 | return isclose(dist, 0.0, abs_tol=abs_tol) 14 | return False 15 | 16 | 17 | def dist_to_line_segment_squared(point, vec_a, vec_b): 18 | cp , l = closest_to_line(point, vec_a, vec_b) 19 | return (point - cp).length_squared, l 20 | 21 | def closest_to_line(point, vec_a, vec_b)-> Vector: 22 | o: Vector = vec_a 23 | d: Vector = vec_a - vec_b 24 | h: Vector = point - o 25 | lambda_ = d.dot(h)/d.length_squared 26 | 27 | return (o + d * lambda_), -lambda_ 28 | 29 | def project_point_plane(point: Vector, plane_n: Vector): 30 | """ Project a point onto the plane. 31 | 32 | Args: 33 | point: The point to project. 34 | plane_n: Normalized vector perpendicular to the plane. 35 | """ 36 | proj_vec = point.project(plane_n) 37 | return point - proj_vec 38 | 39 | 40 | def ray_plane_intersection(ray_origin, ray_dir_vec, plane_origin, plane_n): 41 | denom = plane_n.dot(ray_dir_vec) 42 | if denom == 0: 43 | return 0 44 | 45 | return (plane_origin - ray_origin).dot(plane_n) / denom 46 | 47 | 48 | def remap(imin, imax, omin, omax, v, clamp_val=False): 49 | 50 | if imax - imin == 0: 51 | return 1/v 52 | new_val = (v - imin) / (imax - imin) * (omax - omin) + omin 53 | 54 | if clamp_val: 55 | if (omin < omax): 56 | return clamp(omin, new_val, omax) 57 | else: 58 | return clamp(omax, new_val, omin) 59 | 60 | return new_val 61 | 62 | def scale_points(vec, space_mat: Matrix, points): 63 | ret = [] 64 | scale_mat = Matrix() 65 | scale_mat[0][0] = vec[0] 66 | scale_mat[1][1] = vec[1] 67 | scale_mat[2][2] = vec[2] 68 | 69 | space_mat_inv = space_mat.inverted() 70 | mat = space_mat_inv @ scale_mat @ space_mat 71 | 72 | for point in points: 73 | point = mat @ point 74 | ret.append(point) 75 | 76 | return ret 77 | 78 | def scale_points_about_origin(points, origin, factor): 79 | mat = Matrix.Translation((-origin[0], -origin[1], -origin[2])) 80 | 81 | return scale_points([factor, factor, factor], mat, points) 82 | 83 | def scale_points_along_line(points, start, end, factor): 84 | origin = (start + end) * 0.5 85 | 86 | return scale_points_about_origin(points, origin, factor) 87 | 88 | def constrain_point_to_line_seg(min: Vector, point: Vector, max: Vector): 89 | """ Constrain a point to the range of the line segment defined by the min and max vectors. 90 | 91 | Args: 92 | 93 | min: Vector that defines the start of the segment. 94 | point: The point we are constraining. 95 | max: Vector that defines the end of the segment. 96 | 97 | Returns: 98 | percent < 0: min. 99 | percent > 1: max 100 | 0 <= percent <=1: point 101 | """ 102 | _, percent = intersect_point_line(point, min, max) 103 | if 0.0 <= percent <= 1.0: 104 | return point 105 | elif percent < 0.0: 106 | return min 107 | else: 108 | return max 109 | 110 | def clamp(minvalue, value, maxvalue): 111 | return max(minvalue, min(value, maxvalue)) 112 | 113 | 114 | def location_3d_to_2d(loc: Vector, context_override=None): 115 | region = None 116 | rv3d = None 117 | if context_override is None: 118 | context = bpy.context 119 | region = context.region 120 | rv3d = context.space_data.region_3d 121 | else: 122 | region = context_override["region"] 123 | rv3d = context_override["space"].region_3d 124 | 125 | return location_3d_to_region_2d(region, rv3d, loc) 126 | 127 | 128 | def inv_lerp(a, b, value): 129 | ab = b - a 130 | av = value - a 131 | 132 | d = ab.dot(ab) 133 | 134 | if d != 0.0: 135 | return av.dot(ab) / d 136 | 137 | return 0.0 138 | 139 | 140 | def normalize_vector(vec:Vector, unit_len): 141 | normalized_vec = None 142 | d = vec.dot(vec) 143 | if d > 1.0e-35: 144 | d = d**0.5 145 | normalized_vec = vec * (unit_len/d) 146 | else: 147 | normalized_vec = Vector() 148 | 149 | return normalized_vec 150 | 151 | 152 | def ortho_basis_from_normal(normal: Vector): 153 | epsilon = 1.192092896e-07 154 | len_sq = normal.x**2 + normal.y**2 155 | if len_sq > epsilon: 156 | d = 1.0 / (len_sq**0.5) 157 | 158 | ortho_vec_a = Vector() 159 | 160 | ortho_vec_a[0] = normal.y * d 161 | ortho_vec_a[1] = -normal.x * d 162 | ortho_vec_a[2] = 0.0 163 | 164 | ortho_vec_b = normal.cross(ortho_vec_a) 165 | 166 | return ortho_vec_a, ortho_vec_b 167 | else: 168 | ortho_vec_a = Vector() 169 | ortho_vec_b = Vector() 170 | 171 | ortho_vec_a.x = -1 if normal.y < 0 else 1 172 | ortho_vec_a.y = 0 173 | ortho_vec_a.z = 0 174 | 175 | ortho_vec_b.x = 0 176 | ortho_vec_b.y = 1 177 | ortho_vec_b.z = 0 178 | 179 | return ortho_vec_a, ortho_vec_b 180 | 181 | def basis_mat_from_plane_normal(normal:Vector)->Matrix: 182 | basis_mat = Matrix().to_3x3() 183 | 184 | x, y = ortho_basis_from_normal(normal) 185 | if x is not None and y is not None: 186 | basis_mat[0] = x 187 | basis_mat[1] = y 188 | basis_mat[2] = normal 189 | 190 | basis_mat.transpose() 191 | return basis_mat 192 | 193 | def rotate_direction_vec(vec: Vector, rot_mat: Matrix)-> Vector: 194 | return rot_mat.to_quaternion() @ vec 195 | 196 | def cm_to_meters(cms): 197 | return cms / 100 198 | 199 | def meters_to_cm(meters): 200 | return meters * 100 201 | 202 | def mm_to_meters(mms): 203 | return mms / 1000 204 | 205 | def meters_to_mm(meters): 206 | return meters * 1000 -------------------------------------------------------------------------------- /addon/utils/observer.py: -------------------------------------------------------------------------------- 1 | 2 | class Subject(): 3 | _listeners = {} 4 | 5 | @classmethod 6 | def notify_listeners(cls, *args, **kwargs): 7 | for callback in list(cls._listeners.values()): 8 | try: 9 | callback(*args, **kwargs) 10 | except: 11 | pass 12 | 13 | @classmethod 14 | def register_listener(cls, listener, callback): 15 | cls._listeners[listener.__class__.__name__] = callback 16 | return callback 17 | 18 | 19 | @classmethod 20 | def unregister_listener(cls, listener): 21 | if listener.__class__.__name__ in cls._listeners: 22 | del cls._listeners[listener.__class__.__name__] 23 | -------------------------------------------------------------------------------- /addon/utils/ops.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | import bpy 3 | 4 | 5 | def cursor_warp(event: bpy.types.Event): 6 | ''' 7 | Warp the cursor to keep it inside the active area. 8 | 9 | Args: 10 | event: Modal operator event. 11 | ''' 12 | 13 | area = bpy.context.area 14 | prefs = bpy.context.preferences 15 | offset = prefs.view.ui_scale * 100 16 | 17 | left = area.x + offset 18 | right = area.x + area.width - offset 19 | x = event.mouse_x 20 | 21 | down = area.y + offset 22 | up = area.y + area.height - offset 23 | y = event.mouse_y 24 | 25 | if x < left: 26 | x = right + x - left 27 | elif x > right: 28 | x = left + x - right 29 | 30 | if y < down: 31 | y = up + y - down 32 | elif y > up: 33 | y = down + y - up 34 | 35 | if x != event.mouse_x or y != event.mouse_y: 36 | bpy.context.window.cursor_warp(int(x), int(y)) 37 | 38 | def get_m_button_map(button): 39 | select_mouse_val = bpy.context.window_manager.keyconfigs.user.keymaps['3D View'].keymap_items['view3d.select'].type 40 | if button == 'LEFTMOUSE': 41 | return 'LEFTMOUSE' if select_mouse_val == "LEFTMOUSE" else 'RIGHTMOUSE' 42 | 43 | if button == 'RIGHTMOUSE': 44 | return 'RIGHTMOUSE' if select_mouse_val == "LEFTMOUSE" else 'LEFTMOUSE' 45 | 46 | undo_history_keymap = None 47 | KeyMapItem = namedtuple("KeyMapItem", "type value ctrl alt shift") 48 | def get_undo_keymapping(): 49 | global undo_history_keymap 50 | item = bpy.context.window_manager.keyconfigs.user.keymaps['Screen'].keymap_items.get('ed.undo', None) 51 | if item is not None: 52 | return KeyMapItem(item.type, item.value, item.ctrl, item.alt, item.shift) 53 | elif undo_history_keymap is not None: 54 | return undo_history_keymap 55 | return None 56 | 57 | 58 | def match_event_to_keymap(event, key_map_item): 59 | return (event.type, event.value, event.ctrl, event.alt, event.shift) == key_map_item 60 | 61 | 62 | def set_undo_history_keymap(): 63 | global undo_history_keymap 64 | for item in bpy.context.window_manager.keyconfigs.user.keymaps['Window'].keymap_items.values(): 65 | if item.name in {'Undo History'}: 66 | undo_history_keymap = KeyMapItem(item.type, item.value, item.ctrl, item.alt, item.shift) 67 | 68 | def clear_undo_history_keymap(): 69 | global undo_history_keymap 70 | undo_history_keymap = None 71 | 72 | 73 | def fl_props(): 74 | context = bpy.context 75 | if hasattr(context.window_manager, "fl_props"): 76 | return context.window_manager.fl_props 77 | return None 78 | 79 | def set_fl_prop(property, value)-> bool: 80 | context = bpy.context 81 | if hasattr(context.window_manager, "fl_props"): 82 | if hasattr(context.window_manager.fl_props, property): 83 | setattr(context.window_manager.fl_props, property, value) 84 | return True 85 | return False 86 | 87 | 88 | def options(): 89 | context = bpy.context 90 | if hasattr(context.scene, "fl_options"): 91 | return context.scene.fl_options 92 | return None 93 | 94 | 95 | def set_option(option, value)-> bool: 96 | context = bpy.context 97 | if hasattr(context.scene, "fl_options"): 98 | if hasattr(context.scene.fl_options, option): 99 | setattr(context.scene.fl_options, option, value) 100 | return True 101 | return False 102 | 103 | 104 | def ls_options(): 105 | context = bpy.context 106 | if hasattr(context.window_manager, "ls_options"): 107 | return context.window_manager.ls_options 108 | return None 109 | 110 | 111 | def set_ls_option(option, value)-> bool: 112 | context = bpy.context 113 | if hasattr(context.window_manager, "ls_options"): 114 | if hasattr(context.window_manager.ls_options, option): 115 | setattr(context.window_manager.ls_options, option, value) 116 | return True 117 | return False -------------------------------------------------------------------------------- /addon/utils/raycast.py: -------------------------------------------------------------------------------- 1 | from bpy_extras.view3d_utils import region_2d_to_origin_3d, region_2d_to_vector_3d 2 | 3 | from mathutils.geometry import intersect_line_line 4 | 5 | def get_ray(region, rv3d, mouse_coords): 6 | 7 | ray_origin = region_2d_to_origin_3d(region, rv3d, mouse_coords) 8 | ray_dir_vec = region_2d_to_vector_3d(region, rv3d, mouse_coords) 9 | 10 | return ray_origin, ray_dir_vec 11 | 12 | def get_mouse_line_isect(context, mouse_coords, p1, p2): 13 | mouse_pos, mouse_dir = get_ray(context.region, context.region_data, mouse_coords) 14 | isect_points = intersect_line_line( p1, p2, mouse_pos, mouse_pos + (mouse_dir * 10000.0)) 15 | return isect_points if isect_points is not None else (None, None) 16 | -------------------------------------------------------------------------------- /addon/utils/safety.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | import functools 3 | 4 | 5 | def respond(self, context, event=None): 6 | self.report({'ERROR'}, traceback.format_exc()) 7 | 8 | if hasattr(self, 'cancel'): 9 | try: 10 | self.cancel(context) 11 | except: 12 | self.report({'ERROR'}, traceback.format_exc()) 13 | 14 | return {'CANCELLED'} 15 | 16 | 17 | def decorator(method): 18 | wraps = functools.wraps(method) 19 | 20 | def wrapper(*args): 21 | try: 22 | return method(*args) 23 | except: 24 | return respond(*args) 25 | 26 | if method.__name__ in {'invoke', 'modal'}: 27 | return wraps(lambda self, context, event: wrapper(self, context, event)) 28 | 29 | elif method.__name__ == 'execute': 30 | return wraps(lambda self, context: wrapper(self, context)) 31 | 32 | raise Exception('This decorator is only for invoke, modal, and execute') 33 | -------------------------------------------------------------------------------- /addon/utils/serialization.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | class JSONDeserializer(): 4 | def __init__(self, filepath) -> None: 5 | self.filepath = filepath 6 | 7 | def deserialize(self): 8 | with open(self.filepath, "r") as file: 9 | return json.load(file) 10 | 11 | 12 | class JSONSerializer(): 13 | def __init__(self, filepath) -> None: 14 | self.filepath = filepath 15 | 16 | def serialize(self, data): 17 | with open(self.filepath, "w") as file: 18 | json.dump(data, file) 19 | 20 | -------------------------------------------------------------------------------- /addon/utils/shaders.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | from gpu import shader, types 4 | 5 | def _load_shaders(vertex_shader_source, fragment_shader_source, out_shader_info): 6 | 7 | folder = pathlib.Path(__file__).parent.parent.joinpath('shaders') 8 | 9 | out_shader_info.vertex_source(folder.joinpath(vertex_shader_source).read_text()) 10 | out_shader_info.fragment_source(folder.joinpath(fragment_shader_source).read_text()) 11 | 12 | 13 | def create_2d_shader(vertex_shader="icon_vs.glsl", fragment_shader="icon_test_fs.glsl"): 14 | 15 | shader_2d_info = types.GPUShaderCreateInfo() 16 | 17 | # Defines vertex shader inputs and uniforms that are now called constants. 18 | 19 | shader_2d_info.vertex_in(0, 'VEC2', "pos") 20 | # shader_2d_info.vertex_in(1, 'VEC2', "texCoord") 21 | shader_2d_info.push_constant('FLOAT', "u_scale") 22 | shader_2d_info.push_constant('VEC2', "u_position") 23 | shader_2d_info.push_constant('MAT4', "ModelViewProjectionMatrix") 24 | shader_2d_info.push_constant('MAT4', "ModelMatrix") 25 | shader_2d_info.push_constant('VEC4', "Color") 26 | shader_2d_info.push_constant('BOOL', "u_active") 27 | 28 | # Define as Interface the attributes that will be transferred from the vertex shader to the fragment shader. 29 | # Before they would be both a vertex shader output and fragment shader input. 30 | # An interface can be flat(), no_perspective() or smooth() 31 | # Warning: You need to give a string to the GPUStageInterfaceInfo() or the shader will not work. Any string will work. 32 | shader_2d_interface = types.GPUStageInterfaceInfo("shader_2d_interface") 33 | shader_2d_interface.smooth('VEC2', "texCoord_interp") 34 | shader_2d_info.vertex_out(shader_2d_interface) 35 | 36 | # fragment shader output 37 | shader_2d_info.fragment_out(0, 'VEC4', 'fragColor') 38 | 39 | _load_shaders(vertex_shader, fragment_shader, shader_2d_info) 40 | created_shader = shader.create_from_info(shader_2d_info) 41 | del shader_2d_info 42 | del shader_2d_interface 43 | return created_shader -------------------------------------------------------------------------------- /bl_ui_widgets/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/bl_ui_button.cpython-37.pyc 2 | __pycache__/__init__.cpython-37.pyc 3 | __pycache__/bl_ui_button.cpython-37.pyc 4 | __pycache__/bl_ui_checkbox.cpython-37.pyc 5 | __pycache__/bl_ui_drag_panel.cpython-37.pyc 6 | __pycache__/bl_ui_draw_op.cpython-37.pyc 7 | __pycache__/bl_ui_label.cpython-37.pyc 8 | __pycache__/bl_ui_slider.cpython-37.pyc 9 | __pycache__/bl_ui_up_down.cpython-37.pyc 10 | __pycache__/bl_ui_widget.cpython-37.pyc -------------------------------------------------------------------------------- /bl_ui_widgets/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.enabled": false, 3 | "blender.addon.reloadOnSave": true 4 | } -------------------------------------------------------------------------------- /bl_ui_widgets/README.md: -------------------------------------------------------------------------------- 1 | # Blender UI Widgets 2 | Addon with UI Widgets like a dragable panel, buttons or sliders for Blender 2.8. 3 | 4 | For drawing the GPU module of Blender 2.8 is used. 5 | -------------------------------------------------------------------------------- /bl_ui_widgets/__init__.py: -------------------------------------------------------------------------------- 1 | bl_info = { 2 | "name": "BL UI Widgets", 3 | "description": "UI Widgets to draw in the 3D view", 4 | "author": "Jayanam", 5 | "version": (0, 6, 4, 2), 6 | "blender": (2, 80, 0), 7 | "location": "View3D", 8 | "category": "Object"} 9 | 10 | # Blender imports 11 | import bpy 12 | 13 | from bpy.props import * 14 | 15 | from . drag_panel_op import DP_OT_draw_operator 16 | 17 | addon_keymaps = [] 18 | 19 | def register(): 20 | 21 | bpy.utils.register_class(DP_OT_draw_operator) 22 | kcfg = bpy.context.window_manager.keyconfigs.addon 23 | if kcfg: 24 | km = kcfg.keymaps.new(name='3D View', space_type='VIEW_3D') 25 | 26 | kmi = km.keymap_items.new("object.dp_ot_draw_operator", 'F', 'PRESS', shift=True, ctrl=True) 27 | 28 | addon_keymaps.append((km, kmi)) 29 | 30 | def unregister(): 31 | for km, kmi in addon_keymaps: 32 | km.keymap_items.remove(kmi) 33 | addon_keymaps.clear() 34 | 35 | bpy.utils.unregister_class(DP_OT_draw_operator) 36 | 37 | if __name__ == "__main__": 38 | register() 39 | -------------------------------------------------------------------------------- /bl_ui_widgets/bl_ui_button.py: -------------------------------------------------------------------------------- 1 | from . bl_ui_widget import * 2 | 3 | import blf 4 | import bpy 5 | 6 | class BL_UI_Button(BL_UI_Widget): 7 | 8 | def __init__(self, x, y, width, height): 9 | super().__init__(x, y, width, height) 10 | self._text_color = (1.0, 1.0, 1.0, 1.0) 11 | self._hover_bg_color = (0.5, 0.5, 0.5, 1.0) 12 | self._select_bg_color = (0.7, 0.7, 0.7, 1.0) 13 | 14 | self._text = "Button" 15 | self._text_size = 16 16 | self._textpos = (x, y) 17 | 18 | self._state = 0 19 | self._image = None 20 | self._image_size = (24, 24) 21 | self._image_position = (4, 2) 22 | 23 | @property 24 | def text_color(self): 25 | return self._text_color 26 | 27 | @text_color.setter 28 | def text_color(self, value): 29 | self._text_color = value 30 | 31 | @property 32 | def text(self): 33 | return self._text 34 | 35 | @text.setter 36 | def text(self, value): 37 | self._text = value 38 | 39 | @property 40 | def text_size(self): 41 | return self._text_size 42 | 43 | @text_size.setter 44 | def text_size(self, value): 45 | self._text_size = value 46 | 47 | @property 48 | def hover_bg_color(self): 49 | return self._hover_bg_color 50 | 51 | @hover_bg_color.setter 52 | def hover_bg_color(self, value): 53 | self._hover_bg_color = value 54 | 55 | @property 56 | def select_bg_color(self): 57 | return self._select_bg_color 58 | 59 | @select_bg_color.setter 60 | def select_bg_color(self, value): 61 | self._select_bg_color = value 62 | 63 | def set_image_size(self, imgage_size): 64 | self._image_size = imgage_size 65 | 66 | def set_image_position(self, image_position): 67 | self._image_position = image_position 68 | 69 | def set_image(self, rel_filepath): 70 | try: 71 | self._image = bpy.data.images.load(rel_filepath, check_existing=True) 72 | self._image.gl_load() 73 | except: 74 | pass 75 | 76 | def update(self, x, y): 77 | super().update(x, y) 78 | self._textpos = [x, y] 79 | 80 | def draw(self): 81 | if not self.visible: 82 | return 83 | 84 | area_height = self.get_area_height() 85 | 86 | self.shader.bind() 87 | 88 | self.set_colors() 89 | 90 | bgl.glEnable(bgl.GL_BLEND) 91 | 92 | self.batch_panel.draw(self.shader) 93 | 94 | self.draw_image() 95 | 96 | bgl.glDisable(bgl.GL_BLEND) 97 | 98 | # Draw text 99 | self.draw_text(area_height) 100 | 101 | def set_colors(self): 102 | color = self._bg_color 103 | text_color = self._text_color 104 | 105 | # pressed 106 | if self._state == 1: 107 | color = self._select_bg_color 108 | 109 | # hover 110 | elif self._state == 2: 111 | color = self._hover_bg_color 112 | 113 | self.shader.uniform_float("color", color) 114 | 115 | def draw_text(self, area_height): 116 | blf.size(0, self._text_size, 72) 117 | size = blf.dimensions(0, self._text) 118 | 119 | textpos_y = area_height - self._textpos[1] - (self.height + size[1]) / 2.0 120 | blf.position(0, self._textpos[0] + (self.width - size[0]) / 2.0, textpos_y + 1, 0) 121 | 122 | r, g, b, a = self._text_color 123 | blf.color(0, r, g, b, a) 124 | 125 | blf.draw(0, self._text) 126 | 127 | def draw_image(self): 128 | if self._image is not None: 129 | try: 130 | y_screen_flip = self.get_area_height() - self.y_screen 131 | 132 | off_x, off_y = self._image_position 133 | sx, sy = self._image_size 134 | 135 | # bottom left, top left, top right, bottom right 136 | vertices = ( 137 | (self.x_screen + off_x, y_screen_flip - off_y), 138 | (self.x_screen + off_x, y_screen_flip - sy - off_y), 139 | (self.x_screen + off_x + sx, y_screen_flip - sy - off_y), 140 | (self.x_screen + off_x + sx, y_screen_flip - off_y)) 141 | 142 | self.shader_img = gpu.shader.from_builtin('2D_IMAGE') 143 | self.batch_img = batch_for_shader(self.shader_img, 'TRI_FAN', 144 | { "pos" : vertices, 145 | "texCoord": ((0, 1), (0, 0), (1, 0), (1, 1)) 146 | },) 147 | 148 | bgl.glActiveTexture(bgl.GL_TEXTURE0) 149 | bgl.glBindTexture(bgl.GL_TEXTURE_2D, self._image.bindcode) 150 | 151 | self.shader_img.bind() 152 | self.shader_img.uniform_int("image", 0) 153 | self.batch_img.draw(self.shader_img) 154 | return True 155 | except: 156 | pass 157 | 158 | return False 159 | 160 | def set_mouse_down(self, mouse_down_func): 161 | self.mouse_down_func = mouse_down_func 162 | 163 | def mouse_down(self, x, y): 164 | if self.is_in_rect(x,y): 165 | self._state = 1 166 | try: 167 | self.mouse_down_func() 168 | except: 169 | pass 170 | 171 | return True 172 | 173 | return False 174 | 175 | def mouse_move(self, x, y): 176 | if self.is_in_rect(x,y): 177 | if(self._state != 1): 178 | 179 | # hover state 180 | self._state = 2 181 | else: 182 | self._state = 0 183 | 184 | def mouse_up(self, x, y): 185 | if self.is_in_rect(x,y): 186 | self._state = 2 187 | else: 188 | self._state = 0 -------------------------------------------------------------------------------- /bl_ui_widgets/bl_ui_checkbox.py: -------------------------------------------------------------------------------- 1 | from . bl_ui_widget import * 2 | 3 | import blf 4 | import bpy 5 | 6 | class BL_UI_Checkbox(BL_UI_Widget): 7 | 8 | def __init__(self, x, y, width, height): 9 | super().__init__(x, y, width, height) 10 | self._text_color = (1.0, 1.0, 1.0, 1.0) 11 | self._box_color = (1.0, 1.0, 1.0, 1.0) 12 | self._cross_color = (0.2, 0.9, 0.9, 1.0) 13 | 14 | self._text = "Checkbox" 15 | self._text_size = 16 16 | self._textpos = [x, y] 17 | self.__boxsize = (16,16) 18 | 19 | self.__state = False 20 | 21 | @property 22 | def text_color(self): 23 | return self._text_color 24 | 25 | @text_color.setter 26 | def text_color(self, value): 27 | self._text_color = value 28 | 29 | @property 30 | def cross_color(self): 31 | return self._cross_color 32 | 33 | @cross_color.setter 34 | def cross_color(self, value): 35 | self._cross_color = value 36 | 37 | @property 38 | def text(self): 39 | return self._text 40 | 41 | @text.setter 42 | def text(self, value): 43 | self._text = value 44 | 45 | @property 46 | def text_size(self): 47 | return self._text_size 48 | 49 | @text_size.setter 50 | def text_size(self, value): 51 | self._text_size = value 52 | 53 | @property 54 | def is_checked(self): 55 | return self.__state 56 | 57 | @is_checked.setter 58 | def is_checked(self, value): 59 | if value != self.__state: 60 | self.__state = value 61 | 62 | self.call_state_changed() 63 | 64 | def update(self, x, y): 65 | super().update(x, y) 66 | 67 | self._textpos = [x + 26, y] 68 | 69 | area_height = self.get_area_height() 70 | 71 | y_screen_flip = area_height - self.y_screen 72 | 73 | off_x = 0 74 | off_y = 0 75 | sx, sy = self.__boxsize 76 | 77 | self.shader_chb = gpu.shader.from_builtin('UNIFORM_COLOR') 78 | 79 | # top left, top right, ... 80 | vertices_box = ( 81 | (self.x_screen + off_x, y_screen_flip - off_y - sy), 82 | (self.x_screen + off_x + sx, y_screen_flip - off_y - sy), 83 | (self.x_screen + off_x + sx, y_screen_flip - off_y), 84 | (self.x_screen + off_x, y_screen_flip - off_y)) 85 | 86 | self.batch_box = batch_for_shader(self.shader_chb, 'LINE_LOOP', {"pos": vertices_box}) 87 | 88 | inset = 4 89 | 90 | # cross top-left, bottom-right | top-right, bottom-left 91 | vertices_cross = ( 92 | (self.x_screen + off_x + inset, y_screen_flip - off_y - inset), 93 | (self.x_screen + off_x + sx - inset, y_screen_flip - off_y - sy + inset), 94 | (self.x_screen + off_x + sx - inset, y_screen_flip - off_y - inset), 95 | (self.x_screen + off_x + inset, y_screen_flip - off_y - sy + inset)) 96 | 97 | self.batch_cross = batch_for_shader(self.shader_chb, 'LINES', {"pos": vertices_cross}) 98 | 99 | 100 | def draw(self): 101 | if not self.visible: 102 | return 103 | 104 | area_height = self.get_area_height() 105 | self.shader_chb.bind() 106 | 107 | if self.is_checked: 108 | bgl.glLineWidth(3) 109 | self.shader_chb.uniform_float("color", self._cross_color) 110 | 111 | self.batch_cross.draw(self.shader_chb) 112 | 113 | bgl.glLineWidth(2) 114 | self.shader_chb.uniform_float("color", self._box_color) 115 | 116 | self.batch_box.draw(self.shader_chb) 117 | 118 | # Draw text 119 | self.draw_text(area_height) 120 | 121 | 122 | def draw_text(self, area_height): 123 | blf.size(0, self._text_size, 72) 124 | size = blf.dimensions(0, self._text) 125 | 126 | textpos_y = area_height - self._textpos[1] - (self.height + size[1]) / 2.0 127 | blf.position(0, self._textpos[0], textpos_y + 1, 0) 128 | 129 | r, g, b, a = self._text_color 130 | blf.color(0, r, g, b, a) 131 | 132 | blf.draw(0, self._text) 133 | 134 | def is_in_rect(self, x, y): 135 | area_height = self.get_area_height() 136 | 137 | widget_y = area_height - self.y_screen 138 | if ( 139 | (self.x_screen <= x <= (self.x_screen + self.__boxsize[0])) and 140 | (widget_y >= y >= (widget_y - self.__boxsize[1])) 141 | ): 142 | return True 143 | 144 | return False 145 | 146 | def set_state_changed(self, state_changed_func): 147 | self.state_changed_func = state_changed_func 148 | 149 | def call_state_changed(self): 150 | try: 151 | self.state_changed_func(self, self.__state) 152 | except: 153 | pass 154 | 155 | def toggle_state(self): 156 | self.__state = not self.__state 157 | 158 | def mouse_enter(self, event, x, y): 159 | super().mouse_enter(event, x, y) 160 | if self._mouse_down: 161 | self.toggle_state() 162 | self.call_state_changed() 163 | 164 | def mouse_down(self, x, y): 165 | if self.is_in_rect(x,y): 166 | self.toggle_state() 167 | 168 | self.call_state_changed() 169 | 170 | return True 171 | 172 | return False -------------------------------------------------------------------------------- /bl_ui_widgets/bl_ui_drag_panel.py: -------------------------------------------------------------------------------- 1 | from . bl_ui_widget import * 2 | 3 | class BL_UI_Drag_Panel(BL_UI_Widget): 4 | 5 | def __init__(self, x, y, width, height): 6 | super().__init__(x,y, width, height) 7 | self.drag_offset_x = 0 8 | self.drag_offset_y = 0 9 | self.is_drag = False 10 | self.widgets = [] 11 | 12 | def set_location(self, x, y): 13 | super().set_location(x,y) 14 | self.layout_widgets() 15 | 16 | def add_widget(self, widget): 17 | self.widgets.append(widget) 18 | 19 | def add_widgets(self, widgets): 20 | self.widgets = widgets 21 | self.layout_widgets() 22 | 23 | def layout_widgets(self): 24 | for widget in self.widgets: 25 | widget.update(self.x_screen + widget.x, self.y_screen + widget.y) 26 | 27 | def update(self, x, y): 28 | super().update(x - self.drag_offset_x, y + self.drag_offset_y) 29 | 30 | def child_widget_focused(self, x, y): 31 | for widget in self.widgets: 32 | if widget.is_in_rect(x, y): 33 | return True 34 | return False 35 | 36 | def mouse_down(self, x, y): 37 | if self.child_widget_focused(x, y): 38 | return False 39 | 40 | if self.is_in_rect(x,y): 41 | height = self.get_area_height() 42 | self.is_drag = True 43 | self.drag_offset_x = x - self.x_screen 44 | self.drag_offset_y = y - (height - self.y_screen) 45 | return True 46 | 47 | return False 48 | 49 | def mouse_move(self, x, y): 50 | if self.is_drag: 51 | height = self.get_area_height() 52 | self.update(x, height - y) 53 | self.layout_widgets() 54 | 55 | def mouse_up(self, x, y): 56 | self.is_drag = False 57 | self.drag_offset_x = 0 58 | self.drag_offset_y = 0 -------------------------------------------------------------------------------- /bl_ui_widgets/bl_ui_draw_op.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from bpy.types import Operator 4 | 5 | class BL_UI_OT_draw_operator(Operator): 6 | bl_idname = "object.bl_ui_ot_draw_operator" 7 | bl_label = "bl ui widgets operator" 8 | bl_description = "Operator for bl ui widgets" 9 | bl_options = {'REGISTER'} 10 | 11 | def __init__(self): 12 | self.draw_handle = None 13 | self.draw_event = None 14 | self._finished = False 15 | 16 | self.widgets = [] 17 | 18 | def init_widgets(self, context, widgets): 19 | self.widgets = widgets 20 | for widget in self.widgets: 21 | widget.init(context) 22 | 23 | def on_invoke(self, context, event): 24 | pass 25 | 26 | def on_finish(self, context): 27 | self._finished = True 28 | 29 | def invoke(self, context, event): 30 | 31 | self.on_invoke(context, event) 32 | 33 | args = (self, context) 34 | 35 | self.register_handlers(args, context) 36 | 37 | context.window_manager.modal_handler_add(self) 38 | return {"RUNNING_MODAL"} 39 | 40 | def register_handlers(self, args, context): 41 | self.draw_handle = bpy.types.SpaceView3D.draw_handler_add(self.draw_callback_px, args, "WINDOW", "POST_PIXEL") 42 | self.draw_event = context.window_manager.event_timer_add(0.1, window=context.window) 43 | 44 | def unregister_handlers(self, context): 45 | 46 | context.window_manager.event_timer_remove(self.draw_event) 47 | 48 | bpy.types.SpaceView3D.draw_handler_remove(self.draw_handle, "WINDOW") 49 | 50 | self.draw_handle = None 51 | self.draw_event = None 52 | 53 | def handle_widget_events(self, event): 54 | result = False 55 | for widget in self.widgets: 56 | if widget.handle_event(event): 57 | result = True 58 | return result 59 | 60 | def modal(self, context, event): 61 | 62 | if self._finished: 63 | return {'FINISHED'} 64 | 65 | if context.area: 66 | context.area.tag_redraw() 67 | 68 | if self.handle_widget_events(event): 69 | return {'RUNNING_MODAL'} 70 | 71 | if event.type in {"ESC"}: 72 | self.finish() 73 | 74 | return {"PASS_THROUGH"} 75 | 76 | def finish(self): 77 | self.unregister_handlers(bpy.context) 78 | self.on_finish(bpy.context) 79 | 80 | # Draw handler to paint onto the screen 81 | def draw_callback_px(self, op, context): 82 | for widget in self.widgets: 83 | widget.draw() -------------------------------------------------------------------------------- /bl_ui_widgets/bl_ui_label.py: -------------------------------------------------------------------------------- 1 | from . bl_ui_widget import * 2 | 3 | import blf 4 | 5 | class BL_UI_Label(BL_UI_Widget): 6 | 7 | def __init__(self, x, y, width, height): 8 | super().__init__(x, y, width, height) 9 | 10 | self._text_color = (1.0, 1.0, 1.0, 1.0) 11 | self._text = "Label" 12 | self._text_size = 16 13 | 14 | @property 15 | def text_color(self): 16 | return self._text_color 17 | 18 | @text_color.setter 19 | def text_color(self, value): 20 | self._text_color = value 21 | 22 | @property 23 | def text(self): 24 | return self._text 25 | 26 | @text.setter 27 | def text(self, value): 28 | self._text = value 29 | 30 | @property 31 | def text_size(self): 32 | return self._text_size 33 | 34 | @text_size.setter 35 | def text_size(self, value): 36 | self._text_size = value 37 | 38 | def is_in_rect(self, x, y): 39 | return False 40 | 41 | def draw(self): 42 | if not self.visible: 43 | return 44 | 45 | area_height = self.get_area_height() 46 | 47 | blf.size(0, self._text_size, 72) 48 | size = blf.dimensions(0, self._text) 49 | 50 | textpos_y = area_height - self.y_screen - self.height 51 | blf.position(0, self.x_screen, textpos_y, 0) 52 | 53 | r, g, b, a = self._text_color 54 | 55 | blf.color(0, r, g, b, a) 56 | 57 | blf.draw(0, self._text) -------------------------------------------------------------------------------- /bl_ui_widgets/bl_ui_textbox.py: -------------------------------------------------------------------------------- 1 | from . bl_ui_widget import * 2 | 3 | import blf 4 | import bpy 5 | 6 | 7 | class BL_UI_Textbox(BL_UI_Widget): 8 | 9 | def __init__(self, x, y, width, height): 10 | super().__init__(x, y, width, height) 11 | self._text_color = (1.0, 1.0, 1.0, 1.0) 12 | 13 | self._label_color = (1.0, 1.0, 1.0, 1.0) 14 | 15 | self._label_text_color = (0.1, 0.1, 0.1, 1.0) 16 | 17 | self._bg_color = (0.2, 0.2, 0.2, 1.0) 18 | 19 | self._carret_color = (0.0, 0.2, 1.0, 1.0) 20 | 21 | self._offset_letters = 0 22 | 23 | self._carret_pos = 0 24 | 25 | self._input_keys = ['ESC', 'RET', 'BACK_SPACE', 'HOME', 'END', 'LEFT_ARROW', 'RIGHT_ARROW', 'DEL'] 26 | 27 | self.text = "" 28 | self._label = "" 29 | self._text_size = 12 30 | self._textpos = [x, y] 31 | self._max_input_chars = 100 32 | self._label_width = 0 33 | self._is_numeric = False 34 | 35 | @property 36 | def carret_color(self): 37 | return self._carret_color 38 | 39 | @carret_color.setter 40 | def carret_color(self, value): 41 | self._carret_color = value 42 | 43 | @property 44 | def text_color(self): 45 | return self._text_color 46 | 47 | @text_color.setter 48 | def text_color(self, value): 49 | self._text_color = value 50 | 51 | @property 52 | def max_input_chars(self): 53 | return self._max_input_chars 54 | 55 | @max_input_chars.setter 56 | def max_input_chars(self, value): 57 | self._max_input_chars = value 58 | 59 | @property 60 | def text(self): 61 | return self._text 62 | 63 | @text.setter 64 | def text(self, value): 65 | self._text = value 66 | self._carret_pos = len(value) 67 | 68 | if self.context is not None: 69 | self.update_carret() 70 | 71 | @property 72 | def label(self): 73 | return self._label 74 | 75 | @label.setter 76 | def label(self, value): 77 | self._label = value 78 | if self.context is not None: 79 | self.update_label() 80 | 81 | @property 82 | def text_size(self): 83 | return self._text_size 84 | 85 | @text_size.setter 86 | def text_size(self, value): 87 | self._text_size = value 88 | 89 | @property 90 | def has_label(self): 91 | return self._label != "" 92 | 93 | @property 94 | def is_numeric(self): 95 | return self._is_numeric 96 | 97 | @is_numeric.setter 98 | def is_numeric(self, value): 99 | self._is_numeric = value 100 | 101 | def update(self, x, y): 102 | super().update(x, y) 103 | 104 | if self.has_label: 105 | self.update_label() 106 | 107 | self._textpos = [x, y] 108 | self.update_carret() 109 | 110 | def update_label(self): 111 | y_screen_flip = self.get_area_height() - self.y_screen 112 | 113 | size = blf.dimensions(0, self._label) 114 | 115 | self._label_width = size[0] + 12 116 | 117 | # bottom left, top left, top right, bottom right 118 | vertices_outline = ( 119 | (self.x_screen, y_screen_flip), 120 | (self.x_screen + self.width + self._label_width, y_screen_flip), 121 | (self.x_screen + self.width + self._label_width, y_screen_flip - self.height), 122 | (self.x_screen, y_screen_flip - self.height)) 123 | 124 | self.batch_outline = batch_for_shader(self.shader, 'LINE_LOOP', {"pos" : vertices_outline}) 125 | 126 | indices = ((0, 1, 2), (2, 3, 1)) 127 | 128 | lb_x = self.x_screen + self.width 129 | 130 | # bottom left, top left, top right, bottom right 131 | vertices_label_bg = ( 132 | (lb_x, y_screen_flip), 133 | (lb_x + self._label_width, y_screen_flip), 134 | (lb_x, y_screen_flip - self.height), 135 | (lb_x + self._label_width, y_screen_flip - self.height)) 136 | 137 | self.batch_label_bg = batch_for_shader(self.shader, 'TRIS', {"pos" : vertices_label_bg}, indices=indices) 138 | 139 | def get_carret_pos_px(self): 140 | size_all = blf.dimensions(0, self._text) 141 | size_to_carret = blf.dimensions(0, self._text[:self._carret_pos]) 142 | return self.x_screen + (self.width / 2.0) - (size_all[0] / 2.0) + size_to_carret[0] 143 | 144 | def update_carret(self): 145 | 146 | y_screen_flip = self.get_area_height() - self.y_screen 147 | 148 | x = self.get_carret_pos_px() 149 | 150 | # bottom left, top left, top right, bottom right 151 | vertices = ( 152 | (x, y_screen_flip - 6), 153 | (x, y_screen_flip - self.height + 6) 154 | ) 155 | 156 | self.batch_carret = batch_for_shader( 157 | self.shader, 'LINES', {"pos": vertices}) 158 | 159 | def draw(self): 160 | 161 | if not self.visible: 162 | return 163 | 164 | super().draw() 165 | 166 | area_height = self.get_area_height() 167 | 168 | # Draw text 169 | self.draw_text(area_height) 170 | 171 | self.shader.bind() 172 | self.shader.uniform_float("color", self._carret_color) 173 | bgl.glEnable(bgl.GL_LINE_SMOOTH) 174 | bgl.glLineWidth(2) 175 | self.batch_carret.draw(self.shader) 176 | 177 | if self.has_label: 178 | self.shader.uniform_float("color", self._label_color) 179 | bgl.glLineWidth(1) 180 | self.batch_outline.draw(self.shader) 181 | 182 | self.batch_label_bg.draw(self.shader) 183 | 184 | size = blf.dimensions(0, self._label) 185 | 186 | textpos_y = area_height - self.y_screen - (self.height + size[1]) / 2.0 187 | blf.position(0, self.x_screen + self.width + (self._label_width / 2.0) - (size[0] / 2.0), textpos_y + 1, 0) 188 | 189 | r, g, b, a = self._label_text_color 190 | blf.color(0, r, g, b, a) 191 | 192 | blf.draw(0, self._label) 193 | 194 | def set_colors(self): 195 | color = self._bg_color 196 | text_color = self._text_color 197 | 198 | self.shader.uniform_float("color", color) 199 | 200 | def draw_text(self, area_height): 201 | blf.size(0, self._text_size, 72) 202 | size = blf.dimensions(0, self._text) 203 | 204 | textpos_y = area_height - self._textpos[1] - (self.height + size[1]) / 2.0 205 | blf.position(0, self._textpos[0] + (self.width - size[0]) / 2.0, textpos_y + 1, 0) 206 | 207 | r, g, b, a = self._text_color 208 | blf.color(0, r, g, b, a) 209 | 210 | blf.draw(0, self._text) 211 | 212 | def get_input_keys(self): 213 | return self._input_keys 214 | 215 | def text_input(self, event): 216 | 217 | index = self._carret_pos 218 | 219 | if event.ascii != '' and len(self._text) < self.max_input_chars: 220 | value = self._text[:index] + event.ascii + self._text[index:] 221 | if self._is_numeric and not (event.ascii.isnumeric() or event.ascii in ['.', ',', '-']): 222 | return False 223 | 224 | self._text = value 225 | self._carret_pos += 1 226 | elif event.type == 'BACK_SPACE': 227 | if event.ctrl: 228 | self._text = "" 229 | self._carret_pos = 0 230 | elif self._carret_pos > 0: 231 | self._text = self._text[:index-1] + self._text[index:] 232 | self._carret_pos -= 1 233 | 234 | elif event.type == 'DEL': 235 | if self._carret_pos < len(self._text): 236 | self._text = self._text[:index] + self._text[index+1:] 237 | 238 | elif event.type == 'LEFT_ARROW': 239 | if self._carret_pos > 0: 240 | self._carret_pos -= 1 241 | 242 | elif event.type == 'RIGHT_ARROW': 243 | if self._carret_pos < len(self._text): 244 | self._carret_pos += 1 245 | 246 | elif event.type == 'HOME': 247 | self._carret_pos = 0 248 | 249 | elif event.type == 'END': 250 | self._carret_pos = len(self._text) 251 | 252 | self.update_carret() 253 | try: 254 | self.text_changed_func(self, self.context, event) 255 | except: 256 | pass 257 | 258 | return True 259 | 260 | def set_text_changed(self, text_changed_func): 261 | self.text_changed_func = text_changed_func 262 | 263 | def mouse_down(self, x, y): 264 | if self.is_in_rect(x, y): 265 | return True 266 | 267 | return False 268 | 269 | def mouse_move(self, x, y): 270 | pass 271 | 272 | def mouse_up(self, x, y): 273 | pass 274 | -------------------------------------------------------------------------------- /bl_ui_widgets/bl_ui_up_down.py: -------------------------------------------------------------------------------- 1 | from . bl_ui_widget import * 2 | 3 | import blf 4 | 5 | class BL_UI_Up_Down(BL_UI_Widget): 6 | 7 | def __init__(self, x, y): 8 | 9 | self.__up_down_width = 16 10 | self.__up_down_height = 16 11 | 12 | super().__init__(x, y, self.__up_down_width * 2, self.__up_down_height) 13 | 14 | # Text of the numbers 15 | self._text_color = (1.0, 1.0, 1.0, 1.0) 16 | 17 | # Color of the up/down graphics 18 | self._color = (0.5, 0.5, 0.7, 1.0) 19 | 20 | # Hover % select colors of the up/down graphics 21 | self._hover_color = (0.5, 0.5, 0.8, 1.0) 22 | self._select_color = (0.7, 0.7, 0.7, 1.0) 23 | 24 | self._min = 0 25 | self._max = 100 26 | 27 | self.x_screen = x 28 | self.y_screen = y 29 | 30 | self._text_size = 14 31 | self._decimals = 2 32 | 33 | self.__state = 0 34 | self.__up_down_value = round(0, self._decimals) 35 | 36 | @property 37 | def text_color(self): 38 | return self._text_color 39 | 40 | @text_color.setter 41 | def text_color(self, value): 42 | self._text_color = value 43 | 44 | @property 45 | def text_size(self): 46 | return self._text_size 47 | 48 | @text_size.setter 49 | def text_size(self, value): 50 | self._text_size = value 51 | 52 | @property 53 | def color(self): 54 | return self._color 55 | 56 | @color.setter 57 | def color(self, value): 58 | self._color = value 59 | 60 | @property 61 | def hover_color(self): 62 | return self._hover_color 63 | 64 | @hover_color.setter 65 | def hover_color(self, value): 66 | self._hover_color = value 67 | 68 | @property 69 | def select_color(self): 70 | return self._select_color 71 | 72 | @select_color.setter 73 | def select_color(self, value): 74 | self._select_color = value 75 | 76 | @property 77 | def min(self): 78 | return self._min 79 | 80 | @min.setter 81 | def min(self, value): 82 | self._min = value 83 | 84 | @property 85 | def max(self): 86 | return self._max 87 | 88 | @max.setter 89 | def max(self, value): 90 | self._max = value 91 | 92 | @property 93 | def decimals(self): 94 | return self._decimals 95 | 96 | @decimals.setter 97 | def decimals(self, value): 98 | self._decimals = value 99 | 100 | def draw(self): 101 | 102 | if not self.visible: 103 | return 104 | 105 | area_height = self.get_area_height() 106 | 107 | self.shader.bind() 108 | 109 | color = self._color 110 | text_color = self._text_color 111 | 112 | # pressed 113 | if self.__state == 1: 114 | color = self._select_color 115 | 116 | # hover 117 | elif self.__state == 2: 118 | color = self._hover_color 119 | 120 | self.shader.uniform_float("color", color) 121 | 122 | self.batch_up.draw(self.shader) 123 | 124 | color = self._color 125 | 126 | # pressed (down button) 127 | if self.__state == 3: 128 | color = self._select_color 129 | 130 | # hover (down button) 131 | elif self.__state == 4: 132 | color = self._hover_color 133 | 134 | self.shader.uniform_float("color", color) 135 | self.batch_down.draw(self.shader) 136 | 137 | # Draw value text 138 | sFormat = "{:0." + str(self._decimals) + "f}" 139 | blf.size(0, self._text_size, 72) 140 | 141 | sValue = sFormat.format(self.__up_down_value) 142 | size = blf.dimensions(0, sValue) 143 | 144 | y_pos = area_height - self.y_screen - size[1] - 2 145 | x_pos = self.x_screen + 2 * self.__up_down_width + 10 146 | 147 | blf.position(0, x_pos, y_pos, 0) 148 | 149 | r, g, b, a = self._text_color 150 | blf.color(0, r, g, b, a) 151 | 152 | blf.draw(0, sValue) 153 | 154 | def create_up_down_buttons(self): 155 | # Up / down triangle 156 | # 157 | # 0 158 | # 1 /\ 2 159 | # -- 160 | 161 | area_height = self.get_area_height() 162 | 163 | h = self.__up_down_height 164 | w = self.__up_down_width / 2.0 165 | 166 | pos_y = area_height - self.y_screen 167 | pos_x = self.x_screen 168 | 169 | vertices_up = ( 170 | (pos_x + w , pos_y ), 171 | (pos_x , pos_y - h), 172 | (pos_x + 2*w, pos_y - h) 173 | ) 174 | 175 | pos_x += 18 176 | 177 | vertices_down = ( 178 | (pos_x , pos_y ), 179 | (pos_x + w , pos_y - h), 180 | (pos_x + 2*w, pos_y ) 181 | 182 | ) 183 | 184 | self.shader = gpu.shader.from_builtin('UNIFORM_COLOR') 185 | self.batch_up = batch_for_shader(self.shader, 'TRIS', {"pos" : vertices_up}) 186 | self.batch_down = batch_for_shader(self.shader, 'TRIS', {"pos" : vertices_down}) 187 | 188 | def update(self, x, y): 189 | 190 | self.x_screen = x 191 | self.y_screen = y 192 | 193 | self.create_up_down_buttons() 194 | 195 | 196 | def set_value_change(self, value_change_func): 197 | self.value_change_func = value_change_func 198 | 199 | def is_in_up(self, x, y): 200 | 201 | area_height = self.get_area_height() 202 | pos_y = area_height - self.y_screen 203 | 204 | if ( 205 | (self.x_screen <= x <= self.x_screen + self.__up_down_width) and 206 | (pos_y >= y >= pos_y - self.__up_down_height) 207 | ): 208 | return True 209 | 210 | return False 211 | 212 | def is_in_down(self, x, y): 213 | 214 | area_height = self.get_area_height() 215 | pos_y = area_height - self.y_screen 216 | pos_x = self.x_screen + self.__up_down_width + 2 217 | 218 | if ( 219 | (pos_x <= x <= pos_x + self.__up_down_width) and 220 | (pos_y >= y >= pos_y - self.__up_down_height) 221 | ): 222 | return True 223 | 224 | return False 225 | 226 | def is_in_rect(self, x, y): 227 | return self.is_in_up(x,y) or self.is_in_down(x,y) 228 | 229 | def set_value(self, value): 230 | if value < self._min: 231 | value = self._min 232 | if value > self._max: 233 | value = self._max 234 | 235 | if value != self.__up_down_value: 236 | self.__up_down_value = round(value, self._decimals) 237 | 238 | try: 239 | self.value_change_func(self, self.__up_down_value) 240 | except: 241 | pass 242 | 243 | def mouse_down(self, x, y): 244 | if self.is_in_up(x,y): 245 | self.__state = 1 246 | self.inc_value() 247 | return True 248 | 249 | if self.is_in_down(x,y): 250 | self.__state = 3 251 | self.dec_value() 252 | return True 253 | 254 | return False 255 | 256 | def inc_value(self): 257 | self.set_value(self.__up_down_value + 1) 258 | 259 | def dec_value(self): 260 | self.set_value(self.__up_down_value - 1) 261 | 262 | def mouse_move(self, x, y): 263 | if self.is_in_up(x,y): 264 | if(self.__state != 1): 265 | 266 | # hover state 267 | self.__state = 2 268 | 269 | elif self.is_in_down(x,y): 270 | if(self.__state != 3): 271 | 272 | # hover state 273 | self.__state = 4 274 | 275 | else: 276 | self.__state = 0 277 | 278 | def mouse_up(self, x, y): 279 | self.__state = 0 -------------------------------------------------------------------------------- /bl_ui_widgets/bl_ui_widget.py: -------------------------------------------------------------------------------- 1 | import gpu 2 | from gpu import state 3 | 4 | from gpu_extras.batch import batch_for_shader 5 | 6 | class BL_UI_Widget: 7 | 8 | def __init__(self, x, y, width, height): 9 | self._viewport_area = None 10 | self.x = x 11 | self.y = y 12 | self.x_screen = x 13 | self.y_screen = y 14 | self.width = width 15 | self.height = height 16 | self._bg_color = (0.8, 0.8, 0.8, 1.0) 17 | self._tag = None 18 | self.context = None 19 | self._inrect = False 20 | self._mouse_down = False 21 | self._is_visible = True 22 | 23 | def set_location(self, x, y): 24 | self.x = x 25 | self.y = y 26 | self.x_screen = x 27 | self.y_screen = y 28 | self.update(x,y) 29 | 30 | @property 31 | def bg_color(self): 32 | return self._bg_color 33 | 34 | @bg_color.setter 35 | def bg_color(self, value): 36 | self._bg_color = value 37 | 38 | @property 39 | def visible(self): 40 | return self._is_visible 41 | 42 | @visible.setter 43 | def visible(self, value): 44 | self._is_visible = value 45 | 46 | @property 47 | def tag(self): 48 | return self._tag 49 | 50 | @tag.setter 51 | def tag(self, value): 52 | self._tag = value 53 | 54 | def draw(self): 55 | if not self.visible: 56 | return 57 | 58 | self.shader.bind() 59 | self.shader.uniform_float("color", self._bg_color) 60 | 61 | state.blend_set('ALPHA') 62 | self.batch_panel.draw(self.shader) 63 | state.blend_set('NONE') 64 | 65 | def init(self, context): 66 | self.context = context 67 | self._viewport_area = context.area 68 | self.update(self.x, self.y) 69 | 70 | def update(self, x, y): 71 | 72 | area_height = self.get_area_height() 73 | 74 | self.x_screen = x 75 | self.y_screen = y 76 | 77 | indices = ((0, 1, 2), (0, 2, 3)) 78 | 79 | y_screen_flip = area_height - self.y_screen 80 | 81 | # bottom left, top left, top right, bottom right 82 | vertices = ( 83 | (self.x_screen, y_screen_flip), 84 | (self.x_screen, y_screen_flip - self.height), 85 | (self.x_screen + self.width, y_screen_flip - self.height), 86 | (self.x_screen + self.width, y_screen_flip)) 87 | 88 | self.shader = gpu.shader.from_builtin('UNIFORM_COLOR') 89 | self.batch_panel = batch_for_shader(self.shader, 'TRIS', {"pos" : vertices}, indices=indices) 90 | 91 | def handle_event(self, event): 92 | x = event.mouse_region_x 93 | y = event.mouse_region_y 94 | 95 | if(event.type == 'LEFTMOUSE'): 96 | if(event.value == 'PRESS'): 97 | self._mouse_down = True 98 | return self.mouse_down(x, y) 99 | else: 100 | self._mouse_down = False 101 | self.mouse_up(x, y) 102 | 103 | 104 | elif(event.type == 'MOUSEMOVE'): 105 | self.mouse_move(x, y) 106 | 107 | inrect = self.is_in_rect(x, y) 108 | 109 | # we enter the rect 110 | if not self._inrect and inrect: 111 | self._inrect = True 112 | self.mouse_enter(event, x, y) 113 | 114 | # we are leaving the rect 115 | elif self._inrect and not inrect: 116 | self._inrect = False 117 | self.mouse_exit(event, x, y) 118 | 119 | return False 120 | 121 | elif event.value == 'PRESS' and (event.ascii != '' or event.type in self.get_input_keys()): 122 | return self.text_input(event) 123 | 124 | return False 125 | 126 | def get_input_keys(self): 127 | return [] 128 | 129 | def get_area_height(self): 130 | return self._viewport_area.height 131 | 132 | def is_in_rect(self, x, y): 133 | area_height = self.get_area_height() 134 | 135 | widget_y = area_height - self.y_screen 136 | if ( 137 | (self.x_screen <= x <= (self.x_screen + self.width)) and 138 | (widget_y >= y >= (widget_y - self.height)) 139 | ): 140 | return True 141 | 142 | return False 143 | 144 | def text_input(self, event): 145 | return False 146 | 147 | def mouse_down(self, x, y): 148 | return self.is_in_rect(x,y) 149 | 150 | def mouse_up(self, x, y): 151 | pass 152 | 153 | def set_mouse_enter(self, mouse_enter_func): 154 | self.mouse_enter_func = mouse_enter_func 155 | 156 | def call_mouse_enter(self): 157 | try: 158 | if self.mouse_enter_func: 159 | self.mouse_enter_func() 160 | except: 161 | pass 162 | 163 | def mouse_enter(self, event, x, y): 164 | self.call_mouse_enter() 165 | 166 | def set_mouse_exit(self, mouse_exit_func): 167 | self.mouse_exit_func = mouse_exit_func 168 | 169 | def call_mouse_exit(self): 170 | try: 171 | if self.mouse_exit_func: 172 | self.mouse_exit_func() 173 | except: 174 | pass 175 | 176 | def mouse_exit(self, event, x, y): 177 | self.call_mouse_exit() 178 | 179 | def mouse_move(self, x, y): 180 | pass -------------------------------------------------------------------------------- /bl_ui_widgets/drag_panel.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jrome90/Fast-Loop/e874d714f462eb2bbce1bd1bd6e1242820cf3b09/bl_ui_widgets/drag_panel.blend -------------------------------------------------------------------------------- /bl_ui_widgets/drag_panel.blend1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jrome90/Fast-Loop/e874d714f462eb2bbce1bd1bd6e1242820cf3b09/bl_ui_widgets/drag_panel.blend1 -------------------------------------------------------------------------------- /bl_ui_widgets/drag_panel_op.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from bpy.types import Operator 4 | 5 | from . bl_ui_label import * 6 | from . bl_ui_button import * 7 | from . bl_ui_checkbox import * 8 | from . bl_ui_slider import * 9 | from . bl_ui_up_down import * 10 | from . bl_ui_drag_panel import * 11 | from . bl_ui_draw_op import * 12 | 13 | class DP_OT_draw_operator(BL_UI_OT_draw_operator): 14 | 15 | bl_idname = "object.dp_ot_draw_operator" 16 | bl_label = "bl ui widgets custom operator" 17 | bl_description = "Demo operator for bl ui widgets" 18 | bl_options = {'REGISTER'} 19 | 20 | def __init__(self): 21 | 22 | super().__init__() 23 | 24 | self.panel = BL_UI_Drag_Panel(100, 300, 300, 290) 25 | self.panel.bg_color = (0.2, 0.2, 0.2, 0.9) 26 | 27 | self.label = BL_UI_Label(20, 10, 100, 15) 28 | self.label.text = "Size:" 29 | self.label.text_size = 14 30 | self.label.text_color = (0.2, 0.9, 0.9, 1.0) 31 | 32 | self.slider = BL_UI_Slider(20, 50, 260, 30) 33 | self.slider.color= (0.2, 0.8, 0.8, 0.8) 34 | self.slider.hover_color = (0.2, 0.9, 0.9, 1.0) 35 | self.slider.min = 1.0 36 | self.slider.max = 5.0 37 | self.slider.set_value(2.0) 38 | self.slider.decimals = 1 39 | self.slider.show_min_max = True 40 | self.slider.set_value_change(self.on_slider_value_change) 41 | 42 | self.button1 = BL_UI_Button(20, 100, 120, 30) 43 | self.button1.bg_color = (0.2, 0.8, 0.8, 0.8) 44 | self.button1.hover_bg_color = (0.2, 0.9, 0.9, 1.0) 45 | self.button1.text = "Scale" 46 | self.button1.set_image("//img/scale_24.png") 47 | # self.button1.set_image_size((24,24)) 48 | self.button1.set_image_position((4,2)) 49 | self.button1.set_mouse_down(self.button1_press) 50 | 51 | self.button2 = BL_UI_Button(160, 100, 120, 30) 52 | self.button2.bg_color = (0.2, 0.8, 0.8, 0.8) 53 | self.button2.hover_bg_color = (0.2, 0.9, 0.9, 1.0) 54 | self.button2.text = "Rotate" 55 | #self.button2.set_image("//img/rotate.png") 56 | self.button2.set_image_size((24,24)) 57 | self.button2.set_image_position((4,2)) 58 | self.button2.set_mouse_down(self.button2_press) 59 | 60 | self.label_size = BL_UI_Label(20, 162, 40, 15) 61 | self.label_size.text = "Up/Down size:" 62 | self.label_size.text_size = 14 63 | 64 | self.up_down = BL_UI_Up_Down(120, 165) 65 | self.up_down.color = (0.2, 0.8, 0.8, 0.8) 66 | self.up_down.hover_color = (0.2, 0.9, 0.9, 1.0) 67 | self.up_down.min = 1.0 68 | self.up_down.max = 5.0 69 | self.up_down.decimals = 0 70 | 71 | self.up_down.set_value(3.0) 72 | self.up_down.set_value_change(self.on_up_down_value_change) 73 | 74 | self.chb_visibility = BL_UI_Checkbox(20, 210, 100, 15) 75 | self.chb_visibility.text = "Active visible" 76 | self.chb_visibility.text_size = 14 77 | self.chb_visibility.text_color = (0.2, 0.9, 0.9, 1.0) 78 | self.chb_visibility.is_checked = True 79 | self.chb_visibility.set_state_changed(self.on_chb_visibility_state_change) 80 | 81 | self.chb_1 = BL_UI_Checkbox(20, 235, 100, 15) 82 | self.chb_1.text = "Checkbox 2" 83 | self.chb_1.text_size = 14 84 | self.chb_1.text_color = (0.2, 0.9, 0.9, 1.0) 85 | 86 | self.chb_2 = BL_UI_Checkbox(20, 260, 100, 15) 87 | self.chb_2.text = "Checkbox 3" 88 | self.chb_2.text_size = 14 89 | self.chb_2.text_color = (0.2, 0.9, 0.9, 1.0) 90 | 91 | 92 | def on_invoke(self, context, event): 93 | 94 | # Add new widgets here (TODO: perhaps a better, more automated solution?) 95 | widgets_panel = [self.label, self.label_size, self.button1, self.button2, self.slider, self.up_down, self.chb_visibility, self.chb_1, self.chb_2] 96 | widgets = [self.panel] 97 | 98 | widgets += widgets_panel 99 | 100 | self.init_widgets(context, widgets) 101 | 102 | self.panel.add_widgets(widgets_panel) 103 | 104 | # Open the panel at the mouse location 105 | self.panel.set_location(event.mouse_x, 106 | context.area.height - event.mouse_y + 20) 107 | 108 | 109 | def on_chb_visibility_state_change(self, checkbox, state): 110 | active_obj = bpy.context.view_layer.objects.active 111 | if active_obj is not None: 112 | active_obj.hide_viewport = not state 113 | 114 | 115 | def on_up_down_value_change(self, up_down, value): 116 | active_obj = bpy.context.view_layer.objects.active 117 | if active_obj is not None: 118 | active_obj.scale = (1, 1, value) 119 | 120 | def on_slider_value_change(self, slider, value): 121 | active_obj = bpy.context.view_layer.objects.active 122 | if active_obj is not None: 123 | active_obj.scale = (1, 1, value) 124 | 125 | # Button press handlers 126 | def button1_press(self, widget): 127 | self.slider.set_value(3.0) 128 | print("Button '{0}' is pressed".format(widget.text)) 129 | 130 | 131 | def button2_press(self, widget): 132 | print("Button '{0}' is pressed".format(widget.text)) 133 | active_obj = bpy.context.view_layer.objects.active 134 | if active_obj is not None: 135 | active_obj.rotation_euler = (0, 30, 90) -------------------------------------------------------------------------------- /bl_ui_widgets/img/rotate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jrome90/Fast-Loop/e874d714f462eb2bbce1bd1bd6e1242820cf3b09/bl_ui_widgets/img/rotate.png -------------------------------------------------------------------------------- /bl_ui_widgets/img/rotate.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jrome90/Fast-Loop/e874d714f462eb2bbce1bd1bd6e1242820cf3b09/bl_ui_widgets/img/rotate.psd -------------------------------------------------------------------------------- /bl_ui_widgets/img/scale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jrome90/Fast-Loop/e874d714f462eb2bbce1bd1bd6e1242820cf3b09/bl_ui_widgets/img/scale.png -------------------------------------------------------------------------------- /bl_ui_widgets/img/scale.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jrome90/Fast-Loop/e874d714f462eb2bbce1bd1bd6e1242820cf3b09/bl_ui_widgets/img/scale.psd -------------------------------------------------------------------------------- /bl_ui_widgets/img/scale_24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jrome90/Fast-Loop/e874d714f462eb2bbce1bd1bd6e1242820cf3b09/bl_ui_widgets/img/scale_24.png -------------------------------------------------------------------------------- /blender_manifest.toml: -------------------------------------------------------------------------------- 1 | schema_version = "1.0.0" 2 | 3 | id = "Fast_Loop" 4 | version = "2.1.2" 5 | name = "Fast Loop" 6 | tagline = "Quickly insert loop cuts at the mouse position with a preview" 7 | maintainer = "Jrome" 8 | type = "add-on" 9 | 10 | website = "https://github.com/Jrome90/Fast-Loop" 11 | 12 | tags = ["Mesh", "Modeling"] 13 | 14 | blender_version_min = "4.2.0" 15 | 16 | # License conforming to https://spdx.org/licenses/ (use "SPDX: prefix) 17 | # https://docs.blender.org/manual/en/dev/advanced/extensions/licenses.html 18 | license = [ 19 | "SPDX:GPL-2.0-or-later", 20 | ] 21 | -------------------------------------------------------------------------------- /signalslot/.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | source = signalslot 5 | omit = 6 | */tests.py 7 | */test.py 8 | 9 | [report] 10 | ignore_errors = True 11 | exclude_lines = 12 | pragma: no cover 13 | -------------------------------------------------------------------------------- /signalslot/.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 2.7 3 | env: 4 | - TOX_ENV=py27 5 | - TOX_ENV=pep8 6 | - TOX_ENV=py34 7 | install: 8 | - pip install tox coveralls 9 | script: 10 | - tox -e $TOX_ENV 11 | after_success: 12 | - coveralls -v 13 | -------------------------------------------------------------------------------- /signalslot/CHANGELOG: -------------------------------------------------------------------------------- 1 | 0.1.2 Clarified MIT License 2 | 3 | 0.1.1 Add missing weakrefmethod dependency. 4 | 5 | 0.1.0 Thread safety. Slot class. 6 | 7 | 0.0.11 Support for Python 3.4 8 | 9 | 0.0.10 Added a name property to Signal for logging purposes 10 | 11 | 0.0.9 Bugfixes on Task 12 | 13 | 0.0.8 Removed Queue and DynamicState, Added Task 14 | 15 | 0.0.7 Made Queue's deque public. 16 | 17 | 0.0.6 Added a simple queue system. 18 | 19 | 0.0.5 Use a descriptor for DynamicState.fetch_attribute to avoid conflicts. 20 | 21 | 0.0.4 Added signalslot.contrib.dynamic_state. 22 | 23 | 0.0.3 Removed signal.connect_once(), simplified the code and improved 24 | documentation. 25 | 26 | 0.0.2 Added Signal.connect_once() 27 | 28 | 0.0.1 First release. 29 | -------------------------------------------------------------------------------- /signalslot/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2014-2019 Numergy 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /signalslot/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst *.txt README CHANGELOG 2 | recursive-include signalslot *.py 3 | -------------------------------------------------------------------------------- /signalslot/README: -------------------------------------------------------------------------------- 1 | README.rst -------------------------------------------------------------------------------- /signalslot/README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://secure.travis-ci.org/Numergy/signalslot.png?branch=master 2 | :target: http://travis-ci.org/Numergy/signalslot 3 | .. image:: https://img.shields.io/pypi/dm/signalslot.svg 4 | :target: https://crate.io/packages/signalslot 5 | .. image:: https://img.shields.io/pypi/v/signalslot.svg 6 | :target: https://crate.io/packages/signalslot 7 | .. image:: https://coveralls.io/repos/Numergy/signalslot/badge.png 8 | :target: https://coveralls.io/r/Numergy/signalslot 9 | .. image:: https://readthedocs.org/projects/signalslot/badge/?version=latest 10 | :target: https://signalslot.readthedocs.org/en/latest 11 | 12 | signalslot: simple Signal/Slot implementation for Python 13 | ======================================================== 14 | 15 | This package provides a simple and stupid implementation of the `Signal/Slot 16 | pattern `_ for Python. 17 | Wikipedia has a nice introduction: 18 | 19 | Signals and slots is a language construct introduced in Qt for 20 | communication between objects[1] which makes it easy to implement the 21 | Observer pattern while avoiding boilerplate code. 22 | 23 | Rationale against Signal/Slot is detailed in the "Pattern" 24 | section of the documentation. 25 | 26 | Install 27 | ------- 28 | 29 | Install latest stable version:: 30 | 31 | pip install signalslot 32 | 33 | Install development version:: 34 | 35 | pip install -e git+https://github.com/Numergy/signalslot 36 | 37 | Upgrade 38 | ------- 39 | 40 | Upgrade to the last stable version:: 41 | 42 | pip install -U signalslot 43 | 44 | Uninstall 45 | --------- 46 | 47 | :: 48 | 49 | pip uninstall signalslot 50 | -------------------------------------------------------------------------------- /signalslot/docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/signalslot.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/signalslot.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/signalslot" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/signalslot" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /signalslot/docs/index.rst: -------------------------------------------------------------------------------- 1 | .. signalslot documentation master file, created by 2 | sphinx-quickstart on Fri Mar 14 16:49:30 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | .. include:: ../README.rst 7 | 8 | .. toctree:: 9 | :maxdepth: 3 10 | 11 | pattern 12 | usage 13 | 14 | Indices and tables 15 | ================== 16 | 17 | * :ref:`genindex` 18 | * :ref:`modindex` 19 | * :ref:`search` 20 | 21 | -------------------------------------------------------------------------------- /signalslot/docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\signalslot.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\signalslot.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /signalslot/docs/pattern.rst: -------------------------------------------------------------------------------- 1 | Signal/Slot design pattern 2 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | Introduction 5 | ============ 6 | 7 | Signal/Slot is a pattern that allows loose coupling various components of a 8 | software without having to introduce boilerplate code. Loose coupling of 9 | components allows better modularity in software code which has the nice side 10 | effect of making it easier to test because less dependencies means less mocking 11 | and monkey patching. 12 | 13 | Signal/Slot is a widely used pattern, many frameworks have it built-in 14 | including Django, Qt and probably many others. If you have a standalone project 15 | then you probably don't want to add a big dependency like PyQt or Django just 16 | for a Signal/Slot framework. There are a couple of standalone libraries which 17 | allow to acheive a similar result, like Circuits or PyPubSub, which has way 18 | more features than ``signalslots``, like messaging over the network and is a 19 | quite complicated and has weird (non-PyPi hosted) dependencies and is not PEP8 20 | compliant ... 21 | 22 | ``signalslot`` has the vocation of being a light and simple implementation of 23 | the well known Signal/Slot design pattern provided as a classic quality Python 24 | package. 25 | 26 | Tight coupling 27 | ============== 28 | 29 | Consider such a code in ``your_client.py``: 30 | 31 | .. code-block:: python 32 | 33 | import your_service 34 | import your_dirty_hack # WTH is that doing here ? huh ? 35 | 36 | class YourClient(object): 37 | def something_happens(self, some_argument): 38 | your_service.something_happens(some_argument) 39 | your_dirty_hack.something_happens(some_argument) 40 | 41 | The problem with that code is that it ties ``your_client`` with 42 | ``your_service`` and ``your_dirty_hack`` which you really didn't want to put 43 | there, but had to, "until you find a better place for it". 44 | 45 | Tight coupling makes code harder to test because it takes more mocking and 46 | harder to maintain because it has more dependencies. 47 | 48 | An improvement would be to acheive the same while keeping components loosely 49 | coupled. 50 | 51 | Observer pattern 52 | ================ 53 | 54 | You could implement an Observer pattern in ``YourClient`` by adding 55 | boilerplate code: 56 | 57 | .. code-block:: python 58 | 59 | class YourClient(object): 60 | def __init__(self): 61 | self.observers = [] 62 | 63 | def register_observer(self, observer): 64 | self.observers.append(observer) 65 | 66 | def something_happens(self, some_argument): 67 | for observer in self.observers: 68 | observer.something_happens(some_argument) 69 | 70 | This implementation is a bit dumb, it doesn't check the compatibility of 71 | observers for example, also it's additionnal code you'd have to test, and it's 72 | "boilerplate". 73 | 74 | This would work if you have control on instanciation of ``YourClient``, ie.: 75 | 76 | .. code-block:: python 77 | 78 | your_client = YourClient() 79 | your_client.register_observer(your_service) 80 | your_client.register_observer(your_dirty_hack) 81 | 82 | If ``YourClient`` is used by a framework with `IoC 83 | `_ then it might become 84 | harder: 85 | 86 | .. code-block:: python 87 | 88 | service = some_framework.Service.create( 89 | client='your_client.YourClient') 90 | 91 | service._client.register_observer(your_service) 92 | service._client.register_observer(your_dirty_hack) 93 | 94 | In this example, we're accessing a private python variable ``_client`` and 95 | that's never very good because it's not safe against forward compatibility. 96 | 97 | With Signal/Slot 98 | ================ 99 | 100 | Using the Signal/Slot pattern, the same result could be achieved with total 101 | component decoupling. It would organise as such: 102 | 103 | - ``YourClient`` defines a ``something_happens`` signal, 104 | - ``your_service`` connects its own callback to the ``something_happens``, 105 | - so does ``your_dirty_hack``, 106 | - ``YourClient.something_happens()`` "emits" a signal, which in turn calls all 107 | connected callbacks. 108 | 109 | Note that a connected callback is called a "slot" in the "Signal/Slot" pattern. 110 | 111 | See :doc:`usage` for example code. 112 | -------------------------------------------------------------------------------- /signalslot/docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ~~~~~ 3 | 4 | :py:class:`signalslot.Signal` objects 5 | ===================================== 6 | 7 | .. automodule:: signalslot.signal 8 | :members: 9 | -------------------------------------------------------------------------------- /signalslot/setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | verbosity=3 3 | with-doctest=1 4 | detailed-errors=1 5 | with-coverage=1 6 | cover-html=1 7 | cover-tests=1 8 | cover-erase=1 9 | cover-html-dir=build 10 | 11 | [tool:pytest] 12 | addopts = --pep8 --cov signalslot --cov-report html --doctest-modules 13 | python_files = signalslot/*.py 14 | norecursedirs = .git docs .tox 15 | looponfailroots = signalslot 16 | pep8ignore = 17 | */tests.py ALL 18 | 19 | [metadata] 20 | description-file = README.rst 21 | -------------------------------------------------------------------------------- /signalslot/setup.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import signalslot 3 | 4 | from setuptools import setup, find_packages 5 | 6 | 7 | def read(fname): 8 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 9 | 10 | 11 | setup( 12 | name='signalslot', 13 | version=signalslot.__version__, 14 | description='Simple Signal/Slot implementation', 15 | url='https://github.com/numergy/signalslot', 16 | long_description=read('README.rst'), 17 | packages=find_packages(), 18 | include_package_data=True, 19 | license='MIT', 20 | keywords='signal slot', 21 | install_requires=[ 22 | 'six', 23 | 'contexter', 24 | 'weakrefmethod', 25 | ], 26 | classifiers=[ 27 | 'Development Status :: 5 - Production/Stable', 28 | 'Intended Audience :: Developers', 29 | 'License :: OSI Approved :: MIT License', 30 | 'Operating System :: OS Independent', 31 | 'Programming Language :: Python', 32 | 'Programming Language :: Python :: 2', 33 | 'Programming Language :: Python :: 3', 34 | 'Topic :: Software Development :: Libraries :: Python Modules', 35 | ], 36 | ) 37 | 38 | -------------------------------------------------------------------------------- /signalslot/signalslot/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from .signal import Signal 3 | from .slot import Slot 4 | from .exceptions import * 5 | except ImportError: # pragma: no cover 6 | # Possible we are running from setup.py, in which case we're after 7 | # the __version__ string only. 8 | pass 9 | 10 | __version__ = '0.1.2' 11 | -------------------------------------------------------------------------------- /signalslot/signalslot/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jrome90/Fast-Loop/e874d714f462eb2bbce1bd1bd6e1242820cf3b09/signalslot/signalslot/contrib/__init__.py -------------------------------------------------------------------------------- /signalslot/signalslot/contrib/task/__init__.py: -------------------------------------------------------------------------------- 1 | from .task import Task 2 | -------------------------------------------------------------------------------- /signalslot/signalslot/contrib/task/task.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import eventlet 3 | import contexter 4 | import six 5 | 6 | 7 | class Task(object): 8 | @classmethod 9 | def get_or_create(cls, signal, kwargs=None, logger=None): 10 | if not hasattr(cls, '_registry'): 11 | cls._registry = [] 12 | 13 | task = cls(signal, kwargs, logger=logger) 14 | 15 | if task not in cls._registry: 16 | cls._registry.append(task) 17 | 18 | return cls._registry[cls._registry.index(task)] 19 | 20 | def __init__(self, signal, kwargs=None, logger=None): 21 | self.signal = signal 22 | self.kwargs = kwargs or {} 23 | self.logger = logger 24 | self.failures = 0 25 | self.task_semaphore = eventlet.semaphore.BoundedSemaphore(1) 26 | 27 | def __call__(self, semaphores=None): 28 | semaphores = semaphores or [] 29 | 30 | with contexter.Contexter(self.task_semaphore, *semaphores): 31 | result = self._do() 32 | 33 | if result: 34 | self.failures = 0 35 | else: 36 | self.failures += 1 37 | 38 | return result 39 | 40 | def _do(self): 41 | try: 42 | self._emit() 43 | except Exception: 44 | self._exception(*sys.exc_info()) 45 | return False 46 | else: 47 | self._completed() 48 | return True 49 | finally: 50 | self._clean() 51 | 52 | def _clean(self): 53 | pass 54 | 55 | def _completed(self): 56 | if self.logger: 57 | self.logger.info('[%s] Completed' % self) 58 | 59 | def _exception(self, e_type, e_value, e_traceback): 60 | if self.logger: 61 | self.logger.exception('[%s] Raised exception: %s' % ( 62 | self, e_value)) 63 | else: 64 | six.reraise(e_type, e_value, e_traceback) 65 | 66 | def _emit(self): 67 | if self.logger: 68 | self.logger.info('[%s] Running' % self) 69 | self.signal.emit(**self.kwargs) 70 | 71 | def __eq__(self, other): 72 | return (self.signal == other.signal and self.kwargs == other.kwargs) 73 | 74 | def __str__(self): 75 | return '%s: %s' % (self.signal.__class__.__name__, self.kwargs) 76 | -------------------------------------------------------------------------------- /signalslot/signalslot/contrib/task/test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import mock 3 | import logging 4 | import eventlet 5 | import time 6 | from signalslot import Signal 7 | from signalslot.contrib.task import Task 8 | 9 | eventlet.monkey_patch(time=True) 10 | 11 | 12 | class TestTask(object): 13 | def setup_method(self, method): 14 | self.signal = mock.Mock() 15 | 16 | def get_task_mock(self, *methods, **kwargs): 17 | if kwargs.get('logger'): 18 | log = logging.getLogger('TestTask') 19 | else: 20 | log = None 21 | task_mock = Task(self.signal, logger=log) 22 | 23 | for method in methods: 24 | setattr(task_mock, method, mock.Mock()) 25 | 26 | return task_mock 27 | 28 | def test_eq(self): 29 | x = Task(self.signal, dict(some_kwarg='foo'), 30 | logger=logging.getLogger('TaskX')) 31 | y = Task(self.signal, dict(some_kwarg='foo'), 32 | logger=logging.getLogger('TaskY')) 33 | 34 | assert x == y 35 | 36 | def test_not_eq(self): 37 | x = Task(self.signal, dict(some_kwarg='foo', 38 | logger=logging.getLogger('TaskX'))) 39 | y = Task(self.signal, dict(some_kwarg='bar', 40 | logger=logging.getLogger('TaskY'))) 41 | 42 | assert x != y 43 | 44 | def test_unicode(self): 45 | t = Task(self.signal, dict(some_kwarg='foo'), 46 | logger=logging.getLogger('TaskT')) 47 | 48 | assert str(t) == "Mock: {'some_kwarg': 'foo'}" 49 | 50 | def test_get_or_create_gets(self): 51 | x = Task.get_or_create(self.signal, dict(some_kwarg='foo'), 52 | logger=logging.getLogger('TaskX')) 53 | y = Task.get_or_create(self.signal, dict(some_kwarg='foo'), 54 | logger=logging.getLogger('TaskY')) 55 | 56 | assert x is y 57 | 58 | def test_get_or_create_creates(self): 59 | x = Task.get_or_create(self.signal, dict(some_kwarg='foo'), 60 | logger=logging.getLogger('TaskX')) 61 | y = Task.get_or_create(self.signal, dict(some_kwarg='bar'), 62 | logger=logging.getLogger('TaskY')) 63 | 64 | assert x is not y 65 | 66 | def test_get_or_create_without_kwargs(self): 67 | t = Task.get_or_create(self.signal) 68 | 69 | assert t.kwargs == {} 70 | 71 | def test_get_or_create_uses_cls(self): 72 | class Foo(Task): 73 | pass 74 | 75 | assert isinstance(Foo.get_or_create(self.signal), Foo) 76 | 77 | def test_do_emit(self): 78 | task_mock = self.get_task_mock('_clean', '_exception', '_completed') 79 | 80 | task_mock._do() 81 | 82 | self.signal.emit.assert_called_once_with() 83 | 84 | def test_do_emit_nolog(self): 85 | task_mock = self.get_task_mock( 86 | '_clean', '_exception', '_completed', logging=True) 87 | 88 | task_mock._do() 89 | 90 | self.signal.emit.assert_called_once_with() 91 | 92 | def test_do_emit_no_log(self): 93 | task_mock = self.get_task_mock('_clean', '_exception', '_completed') 94 | 95 | task_mock._do() 96 | 97 | self.signal.emit.assert_called_once_with() 98 | 99 | def test_do_complete(self): 100 | task_mock = self.get_task_mock('_clean', '_exception', '_completed') 101 | 102 | task_mock._do() 103 | 104 | task_mock._exception.assert_not_called() 105 | task_mock._completed.assert_called_once_with() 106 | task_mock._clean.assert_called_once_with() 107 | 108 | def test_do_success(self): 109 | task_mock = self.get_task_mock() 110 | assert task_mock._do() is True 111 | 112 | def test_do_failure_nolog(self): 113 | # Our dummy exception 114 | class DummyError(Exception): 115 | pass 116 | 117 | task_mock = self.get_task_mock('_emit') 118 | task_mock._emit.side_effect = DummyError() 119 | 120 | # This will throw an exception at us, be ready to catch it. 121 | try: 122 | task_mock._do() 123 | assert False 124 | except DummyError: 125 | pass 126 | 127 | def test_do_failure_withlog(self): 128 | task_mock = self.get_task_mock('_emit', logger=True) 129 | task_mock._emit.side_effect = Exception() 130 | assert task_mock._do() is False 131 | 132 | def test_do_exception(self): 133 | task_mock = self.get_task_mock( 134 | '_clean', '_exception', '_completed', '_emit') 135 | 136 | task_mock._emit.side_effect = Exception() 137 | 138 | task_mock._do() 139 | 140 | task_mock._exception.assert_called_once_with( 141 | Exception, task_mock._emit.side_effect, mock.ANY) 142 | 143 | task_mock._completed.assert_not_called() 144 | task_mock._clean.assert_called_once_with() 145 | 146 | @mock.patch('signalslot.signal.inspect') 147 | def test_semaphore(self, inspect): 148 | slot = mock.Mock() 149 | slot.side_effect = lambda **k: time.sleep(.3) 150 | 151 | signal = Signal('tost') 152 | signal.connect(slot) 153 | 154 | x = Task.get_or_create(signal, dict(some_kwarg='foo'), 155 | logger=logging.getLogger('TaskX')) 156 | y = Task.get_or_create(signal, dict(some_kwarg='foo'), 157 | logger=logging.getLogger('TaskY')) 158 | 159 | eventlet.spawn(x) 160 | time.sleep(.1) 161 | eventlet.spawn(y) 162 | time.sleep(.1) 163 | 164 | assert slot.call_count == 1 165 | time.sleep(.4) 166 | assert slot.call_count == 2 167 | 168 | def test_call_context(self): 169 | task_mock = self.get_task_mock('_clean', '_exception', '_completed', 170 | '_emit') 171 | 172 | task_mock._emit.side_effect = Exception() 173 | 174 | assert task_mock.failures == 0 175 | task_mock() 176 | assert task_mock.failures == 1 177 | 178 | def test_call_success(self): 179 | task_mock = self.get_task_mock('_clean', '_exception', '_completed', 180 | '_emit') 181 | 182 | assert task_mock.failures == 0 183 | task_mock() 184 | assert task_mock.failures == 0 185 | -------------------------------------------------------------------------------- /signalslot/signalslot/exceptions.py: -------------------------------------------------------------------------------- 1 | class SignalSlotException(Exception): 2 | """Base class for all exceptions of this module.""" 3 | pass 4 | 5 | 6 | class SlotMustAcceptKeywords(SignalSlotException): 7 | """ 8 | Raised when connecting a slot that does not accept ``**kwargs`` in its 9 | signature. 10 | """ 11 | def __init__(self, signal, slot): 12 | m = 'Cannot connect %s to %s because it does not accept **kwargs' % ( 13 | slot, signal) 14 | 15 | super(SlotMustAcceptKeywords, self).__init__(m) 16 | 17 | 18 | # Not yet being used. 19 | class QueueCantQueueNonSignalInstance(SignalSlotException): # pragma: no cover 20 | """ 21 | Raised when trying to queue something else than a 22 | :py:class:`~signalslot.signal.Signal` instance. 23 | """ 24 | def __init__(self, queue, arg): 25 | m = 'Cannot queue %s to %s because it is not a Signal instance' % ( 26 | arg, queue) 27 | 28 | super(QueueCantQueueNonSignalInstance, self).__init__(m) 29 | -------------------------------------------------------------------------------- /signalslot/signalslot/signal.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module defining the Signal class. 3 | """ 4 | 5 | import inspect 6 | import threading 7 | 8 | from . import exceptions 9 | 10 | 11 | class DummyLock(object): 12 | """ 13 | Class that implements a no-op instead of a re-entrant lock. 14 | """ 15 | 16 | def __enter__(self): 17 | pass 18 | 19 | def __exit__(self, exc_type=None, exc_value=None, traceback=None): 20 | pass 21 | 22 | 23 | class BaseSlot(object): 24 | """ 25 | Slot abstract class for type resolution purposes. 26 | """ 27 | pass 28 | 29 | 30 | class Signal(object): 31 | """ 32 | Define a signal by instanciating a :py:class:`Signal` object, ie.: 33 | 34 | >>> conf_pre_load = Signal() 35 | 36 | Optionaly, you can declare a list of argument names for this signal, ie.: 37 | 38 | >>> conf_pre_load = Signal(args=['conf']) 39 | 40 | Any callable can be connected to a Signal, it **must** accept keywords 41 | (``**kwargs``), ie.: 42 | 43 | >>> def yourmodule_conf(conf, **kwargs): 44 | ... conf['yourmodule_option'] = 'foo' 45 | ... 46 | 47 | Connect your function to the signal using :py:meth:`connect`: 48 | 49 | >>> conf_pre_load.connect(yourmodule_conf) 50 | 51 | Emit the signal to call all connected callbacks using 52 | :py:meth:`emit`: 53 | 54 | >>> conf = {} 55 | >>> conf_pre_load.emit(conf=conf) 56 | >>> conf 57 | {'yourmodule_option': 'foo'} 58 | 59 | Note that you may disconnect a callback from a signal if it is already 60 | connected: 61 | 62 | >>> conf_pre_load.is_connected(yourmodule_conf) 63 | True 64 | >>> conf_pre_load.disconnect(yourmodule_conf) 65 | >>> conf_pre_load.is_connected(yourmodule_conf) 66 | False 67 | """ 68 | def __init__(self, args=None, name=None, threadsafe=False): 69 | self._slots = [] 70 | self._slots_lk = threading.RLock() if threadsafe else DummyLock() 71 | self.args = args or [] 72 | self.name = name 73 | 74 | @property 75 | def slots(self): 76 | """ 77 | Return a list of slots for this signal. 78 | """ 79 | with self._slots_lk: 80 | # Do a slot clean-up 81 | slots = [] 82 | for s in self._slots: 83 | if isinstance(s, BaseSlot) and (not s.is_alive): 84 | continue 85 | slots.append(s) 86 | self._slots = slots 87 | return list(slots) 88 | 89 | def connect(self, slot): 90 | """ 91 | Connect a callback ``slot`` to this signal. 92 | """ 93 | if not isinstance(slot, BaseSlot) and \ 94 | inspect.getargspec(slot).keywords is None: 95 | raise exceptions.SlotMustAcceptKeywords(self, slot) 96 | 97 | with self._slots_lk: 98 | if not self.is_connected(slot): 99 | self._slots.append(slot) 100 | 101 | def is_connected(self, slot): 102 | """ 103 | Check if a callback ``slot`` is connected to this signal. 104 | """ 105 | with self._slots_lk: 106 | return slot in self._slots 107 | 108 | def disconnect(self, slot): 109 | """ 110 | Disconnect a slot from a signal if it is connected else do nothing. 111 | """ 112 | with self._slots_lk: 113 | if self.is_connected(slot): 114 | self._slots.pop(self._slots.index(slot)) 115 | 116 | def emit(self, **kwargs): 117 | """ 118 | Emit this signal which will execute every connected callback ``slot``, 119 | passing keyword arguments. 120 | 121 | If a slot returns anything other than None, then :py:meth:`emit` will 122 | return that value preventing any other slot from being called. 123 | 124 | >>> need_something = Signal() 125 | >>> def get_something(**kwargs): 126 | ... return 'got something' 127 | ... 128 | >>> def make_something(**kwargs): 129 | ... print('I will not be called') 130 | ... 131 | >>> need_something.connect(get_something) 132 | >>> need_something.connect(make_something) 133 | >>> need_something.emit() 134 | 'got something' 135 | """ 136 | for slot in self.slots: 137 | result = slot(**kwargs) 138 | 139 | if result is not None: 140 | return result 141 | 142 | def __eq__(self, other): 143 | """ 144 | Return True if other has the same slots connected. 145 | 146 | >>> a = Signal() 147 | >>> b = Signal() 148 | >>> a == b 149 | True 150 | >>> def slot(**kwargs): 151 | ... pass 152 | ... 153 | >>> a.connect(slot) 154 | >>> a == b 155 | False 156 | >>> b.connect(slot) 157 | >>> a == b 158 | True 159 | """ 160 | return self.slots == other.slots 161 | 162 | def __repr__(self): 163 | return '' % (self.name or 'NO_NAME') 164 | -------------------------------------------------------------------------------- /signalslot/signalslot/slot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module defining the Slot class. 3 | """ 4 | 5 | import types 6 | import weakref 7 | import sys 8 | 9 | from .signal import BaseSlot 10 | 11 | # We cannot test a branch for Python >= 3.4 in Python < 3.4. 12 | if sys.version_info < (3, 4): # pragma: no cover 13 | from weakrefmethod import WeakMethod 14 | else: # pragma: no cover 15 | from weakref import WeakMethod 16 | 17 | 18 | class Slot(BaseSlot): 19 | """ 20 | A slot is a callable object that manages a connection to a signal. 21 | If weak is true or the slot is a subclass of weakref.ref, the slot 22 | is automatically de-referenced to the called function. 23 | """ 24 | def __init__(self, slot, weak=False): 25 | self._weak = weak or isinstance(slot, weakref.ref) 26 | if weak and not isinstance(slot, weakref.ref): 27 | if isinstance(slot, types.MethodType): 28 | slot = WeakMethod(slot) 29 | else: 30 | slot = weakref.ref(slot) 31 | self._slot = slot 32 | 33 | @property 34 | def is_alive(self): 35 | """ 36 | Return True if this slot is "alive". 37 | """ 38 | return (not self._weak) or (self._slot() is not None) 39 | 40 | @property 41 | def func(self): 42 | """ 43 | Return the function that is called by this slot. 44 | """ 45 | if self._weak: 46 | return self._slot() 47 | else: 48 | return self._slot 49 | 50 | def __call__(self, **kwargs): 51 | """ 52 | Execute this slot. 53 | """ 54 | func = self.func 55 | if func is not None: 56 | return func(**kwargs) 57 | 58 | def __eq__(self, other): 59 | """ 60 | Compare this slot to another. 61 | """ 62 | if isinstance(other, BaseSlot): 63 | return self.func == other.func 64 | else: 65 | return self.func == other 66 | 67 | def __repr__(self): 68 | fn = self.func 69 | if fn is None: 70 | fn = 'dead' 71 | else: 72 | fn = repr(fn) 73 | return '' % fn 74 | -------------------------------------------------------------------------------- /signalslot/signalslot/tests.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import mock 3 | 4 | from signalslot import Signal, SlotMustAcceptKeywords, Slot 5 | 6 | 7 | @mock.patch('signalslot.signal.inspect') 8 | class TestSignal(object): 9 | def setup_method(self, method): 10 | self.signal_a = Signal(threadsafe=True) 11 | self.signal_b = Signal(args=['foo']) 12 | 13 | self.slot_a = mock.Mock(spec=lambda **kwargs: None) 14 | self.slot_a.return_value = None 15 | self.slot_b = mock.Mock(spec=lambda **kwargs: None) 16 | self.slot_b.return_value = None 17 | 18 | def test_is_connected(self, inspect): 19 | self.signal_a.connect(self.slot_a) 20 | 21 | assert self.signal_a.is_connected(self.slot_a) 22 | assert not self.signal_a.is_connected(self.slot_b) 23 | assert not self.signal_b.is_connected(self.slot_a) 24 | assert not self.signal_b.is_connected(self.slot_b) 25 | 26 | def test_emit_one_slot(self, inspect): 27 | self.signal_a.connect(self.slot_a) 28 | 29 | self.signal_a.emit() 30 | 31 | self.slot_a.assert_called_once_with() 32 | assert self.slot_b.call_count == 0 33 | 34 | def test_emit_two_slots(self, inspect): 35 | self.signal_a.connect(self.slot_a) 36 | self.signal_a.connect(self.slot_b) 37 | 38 | self.signal_a.emit() 39 | 40 | self.slot_a.assert_called_once_with() 41 | self.slot_b.assert_called_once_with() 42 | 43 | def test_emit_one_slot_with_arguments(self, inspect): 44 | self.signal_b.connect(self.slot_a) 45 | 46 | self.signal_b.emit(foo='bar') 47 | 48 | self.slot_a.assert_called_once_with(foo='bar') 49 | assert self.slot_b.call_count == 0 50 | 51 | def test_emit_two_slots_with_arguments(self, inspect): 52 | self.signal_b.connect(self.slot_a) 53 | self.signal_b.connect(self.slot_b) 54 | 55 | self.signal_b.emit(foo='bar') 56 | 57 | self.slot_a.assert_called_once_with(foo='bar') 58 | self.slot_b.assert_called_once_with(foo='bar') 59 | 60 | def test_reconnect_does_not_duplicate(self, inspect): 61 | self.signal_a.connect(self.slot_a) 62 | self.signal_a.connect(self.slot_a) 63 | self.signal_a.emit() 64 | 65 | self.slot_a.assert_called_once_with() 66 | 67 | def test_disconnect_does_not_fail_on_not_connected_slot(self, inspect): 68 | self.signal_a.disconnect(self.slot_b) 69 | 70 | 71 | def test_anonymous_signal_has_nice_repr(): 72 | signal = Signal() 73 | assert repr(signal) == '' 74 | 75 | 76 | def test_named_signal_has_a_nice_repr(): 77 | signal = Signal(name='update_stuff') 78 | assert repr(signal) == '' 79 | 80 | 81 | class TestSignalConnect(object): 82 | def setup_method(self, method): 83 | self.signal = Signal() 84 | 85 | def test_connect_with_kwargs(self): 86 | def cb(**kwargs): 87 | pass 88 | 89 | self.signal.connect(cb) 90 | 91 | def test_connect_without_kwargs(self): 92 | def cb(): 93 | pass 94 | 95 | with pytest.raises(SlotMustAcceptKeywords): 96 | self.signal.connect(cb) 97 | 98 | 99 | class MyTestError(Exception): 100 | pass 101 | 102 | 103 | class TestException(object): 104 | def setup_method(self, method): 105 | self.signal = Signal(threadsafe=False) 106 | self.seen_exception = False 107 | 108 | def failing_slot(**args): 109 | raise MyTestError('die!') 110 | 111 | self.signal.connect(failing_slot) 112 | 113 | def test_emit_exception(self): 114 | try: 115 | self.signal.emit() 116 | except MyTestError: 117 | self.seen_exception = True 118 | 119 | assert self.seen_exception 120 | 121 | 122 | class TestStrongSlot(object): 123 | def setup_method(self, method): 124 | self.called = False 125 | 126 | def slot(**kwargs): 127 | self.called = True 128 | 129 | self.slot = Slot(slot) 130 | 131 | def test_alive(self): 132 | assert self.slot.is_alive 133 | 134 | def test_call(self): 135 | self.slot(testing=1234) 136 | assert self.called 137 | 138 | 139 | class TestWeakFuncSlot(object): 140 | def setup_method(self, method): 141 | self.called = False 142 | 143 | def slot(**kwargs): 144 | self.called = True 145 | 146 | self.slot = Slot(slot, weak=True) 147 | self.slot_ref = slot 148 | 149 | def test_alive(self): 150 | assert self.slot.is_alive 151 | assert repr(self.slot) == '' % repr(self.slot_ref) 152 | 153 | def test_call(self): 154 | self.slot(testing=1234) 155 | assert self.called 156 | 157 | def test_gc(self): 158 | self.slot_ref = None 159 | assert not self.slot.is_alive 160 | assert repr(self.slot) == '' 161 | self.slot(testing=1234) 162 | 163 | 164 | class TestWeakMethodSlot(object): 165 | def setup_method(self, method): 166 | 167 | class MyObject(object): 168 | 169 | def __init__(self): 170 | self.called = False 171 | 172 | def slot(self, **kwargs): 173 | self.called = True 174 | 175 | self.obj_ref = MyObject() 176 | self.slot = Slot(self.obj_ref.slot, weak=True) 177 | self.signal = Signal() 178 | self.signal.connect(self.slot) 179 | 180 | def test_alive(self): 181 | assert self.slot.is_alive 182 | 183 | def test_call(self): 184 | self.signal.emit(testing=1234) 185 | assert self.obj_ref.called 186 | 187 | def test_gc(self): 188 | self.obj_ref = None 189 | assert not self.slot.is_alive 190 | self.signal.emit(testing=1234) 191 | 192 | 193 | class TestSlotEq(object): 194 | def setup_method(self, method): 195 | self.slot_a = Slot(self.slot, weak=False) 196 | self.slot_b = Slot(self.slot, weak=True) 197 | 198 | def slot(self, **kwargs): 199 | pass 200 | 201 | def test_eq_other(self): 202 | assert self.slot_a == self.slot_b 203 | 204 | def test_eq_func(self): 205 | assert self.slot_a == self.slot 206 | -------------------------------------------------------------------------------- /signalslot/test_requirements.txt: -------------------------------------------------------------------------------- 1 | mock 2 | eventlet 3 | pep8 4 | pytest 5 | pytest-cov<2.6 6 | pytest-pep8 7 | pytest-sugar 8 | pytest-xdist 9 | coverage==3.7.1 10 | weakrefmethod 11 | -------------------------------------------------------------------------------- /signalslot/tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,pep8,py34 3 | 4 | [testenv] 5 | commands = py.test --doctest-modules signalslot 6 | deps = -r{toxinidir}/test_requirements.txt 7 | 8 | [testenv:pep8] 9 | commands = pep8 signalslot --repeat --show-source 10 | 11 | [pep8] 12 | exclude = .tox 13 | --------------------------------------------------------------------------------