├── .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 |
--------------------------------------------------------------------------------