2025-07-01
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
__all__ = [
|
||||
'retopoflow',
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import os
|
||||
import bpy
|
||||
import copy
|
||||
import glob
|
||||
import time
|
||||
|
||||
from .rf.rf_helpsystem import RetopoFlow_HelpSystem
|
||||
|
||||
from ..addon_common.common.globals import Globals
|
||||
from ..addon_common.common import ui_core
|
||||
from ..addon_common.common.useractions import ActionHandler
|
||||
from ..addon_common.common.fsm import FSM
|
||||
|
||||
from ..addon_common.cookiecutter.cookiecutter import CookieCutter
|
||||
|
||||
from ..config.keymaps import get_keymaps
|
||||
from ..config.options import options
|
||||
|
||||
|
||||
class RetopoFlow_OpenHelpSystem(CookieCutter, RetopoFlow_HelpSystem):
|
||||
@classmethod
|
||||
def can_start(cls, context):
|
||||
return True
|
||||
|
||||
def blender_ui_set(self):
|
||||
self.viewaa_simplify()
|
||||
# self.manipulator_hide()
|
||||
self.panels_hide()
|
||||
# self.overlays_hide()
|
||||
self.quadview_hide()
|
||||
self.region_darken()
|
||||
self.header_text_set('RetopoFlow')
|
||||
|
||||
def start(self):
|
||||
ui_core.ASYNC_IMAGE_LOADING = options['async image loading']
|
||||
|
||||
self.statusbar_text_set('Press Esc or Tab to exit')
|
||||
|
||||
# preload_help_images.paused = True
|
||||
keymaps = get_keymaps(force_reload=True)
|
||||
self.actions = ActionHandler(self.context, keymaps)
|
||||
self.reload_stylings()
|
||||
self.blender_ui_set()
|
||||
self.helpsystem_open(self.rf_startdoc, done_on_esc=True, closeable=True, on_close=self.done)
|
||||
Globals.ui_document.body.dirty(cause='changed document size', children=True)
|
||||
|
||||
def end(self):
|
||||
self._cc_blenderui_end()
|
||||
|
||||
# def update(self):
|
||||
# preload_help_images.paused = False
|
||||
|
||||
@FSM.on_state('main')
|
||||
def main(self):
|
||||
# print(f'Help System main')
|
||||
if self.actions.pressed({'done', 'done alt0'}):
|
||||
self.done()
|
||||
return
|
||||
if self.actions.pressed({'F12'}):
|
||||
self.reload_stylings()
|
||||
return
|
||||
@@ -0,0 +1,16 @@
|
||||
<dialog class="alertdialog framed moveable closeable {level}">
|
||||
<h1 id='alert-title'></h1>
|
||||
<div class="contents" id='alert-contents'>
|
||||
<article id='alert-message' class="mdown"></article>
|
||||
<details id='alert-details'>
|
||||
<summary>Crash Details</summary>
|
||||
<pre id='alert-report'></pre>
|
||||
<button title="Copy crash details to clipboard" on_mouseclick="copy_to_clipboard()">Copy details to clipboard</button>
|
||||
</details>
|
||||
<div id='alert-checker'></div>
|
||||
</div>
|
||||
<div class="alertdialog-buttons">
|
||||
<button id='alert-close' title="Close this alert window" on_mouseclick="close()">Close</button>
|
||||
<button id='alert-quit' title="Exit RetopoFlow" on_mouseclick="quit()">Exit</button>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,37 @@
|
||||
<dialog class="framed" id="deletedialog" style="width:200px" on_mouseleave="mouseleave_event()" on_keypress="key(event)">
|
||||
<h1>Delete / Dissolve / Collapse</h1>
|
||||
<div class="contents">
|
||||
<div class="collection">
|
||||
<h1>Delete</h1>
|
||||
<div class="contents">
|
||||
<button title='Delete selected vertices' on_mouseclick="act(('Delete','Vertices'))">Vertices</button>
|
||||
<button title='Delete selected edges and vertices' on_mouseclick="act(('Delete','Edges'))">Edges</button>
|
||||
<button title='Delete selected faces, edges, and vertices' on_mouseclick="act(('Delete','Faces'))">Faces</button>
|
||||
<button title='Delete only selected edges & faces' on_mouseclick="act(('Delete','Only Edges & Faces'))">Only Edges & Faces</button>
|
||||
<button title='Delete only selected faces' on_mouseclick="act(('Delete','Only Faces'))">Only Faces</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Dissolve</h1>
|
||||
<div class="contents">
|
||||
<button title='Dissolve selected vertices' on_mouseclick="act(('Dissolve','Vertices'))">Vertices</button>
|
||||
<button title='Dissolve selected edges' on_mouseclick="act(('Dissolve','Edges'))">Edges</button>
|
||||
<button title='Dissolve selected faces' on_mouseclick="act(('Dissolve','Faces'))">Faces</button>
|
||||
<button title='Dissolve selected edge loops' on_mouseclick="act(('Dissolve','Loops'))">Loops</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Collapse</h1>
|
||||
<div class="contents">
|
||||
<button title='Collapse isolated edge and face regions. This can collapse edge-rings as well as regions of connected faces into vertices.' on_mouseclick="act(('Collapse','Edges & Faces'))">Collapse Edges & Faces</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Merge</h1>
|
||||
<div class="contents">
|
||||
<button title='Merge selected vertices at centers.' on_mouseclick="act(('Merge', 'At Center'))">Merge at Center</button>
|
||||
<button title='Merge selected vertices by distance. See Target Cleaning options to adjust distance setting.' on_mouseclick="act(('Merge', 'By Distance'))">Merge by Distance</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,23 @@
|
||||
<dialog class="framed moveable minimizeable" id="geometrydialog-minimized" on_toggle="self.restore_geometry_window(event.target)">
|
||||
<h1>Poly Count</h1>
|
||||
</dialog>
|
||||
|
||||
<dialog class="framed moveable minimizeable" id="geometrydialog" on_toggle="self.minimize_geometry_window(event.target)">
|
||||
<h1>Poly Count</h1>
|
||||
<div class="contents">
|
||||
<table>
|
||||
<tr>
|
||||
<td><div>Verts:</div></td>
|
||||
<td><div id='geometry-verts'>0</div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><div>Edges:</div></td>
|
||||
<td><div id='geometry-edges'>0</div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><div>Faces:</div></td>
|
||||
<td><div id='geometry-faces'>0</div></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script type="python">
|
||||
def open_online(self):
|
||||
url = retopoflow_urls['help docs']
|
||||
path = self.document.body.getElementById('helpsystem-mdown')._src_mdown_path
|
||||
if path:
|
||||
path = path.replace('.md', '.html')
|
||||
url = f'{url}/{path}'
|
||||
bpy.ops.wm.url_open(url=url)
|
||||
</script>
|
||||
|
||||
<dialog class='framed closeable' id='helpsystem' on_close="close()" on_keypress="key(event)">
|
||||
<h1>RetopoFlow Help System</h1>
|
||||
<div class="contents">
|
||||
<article id='helpsystem-mdown' class='mdown'></article>
|
||||
</div>
|
||||
<div id='helpsystem-buttons'>
|
||||
<button title='Click to open table of contents for help' on_mouseclick="self.helpsystem_open('table_of_contents.md')">Table of Contents</button>
|
||||
<button title='Click to open frequently asked questions' on_mouseclick="self.helpsystem_open('faq.md')">FAQ</button>
|
||||
<button title='Click to open online help documents. Note: this is an experimental feature.' on_mouseclick="open_online(self)">View Online Docs</button>
|
||||
<button title='Click to close this help dialog' on_mouseclick="close()">Close (Esc)</button>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,70 @@
|
||||
<dialog id='keymapsystem' class="framed" on_keypress="key(event)">
|
||||
<h1>RetopoFlow Keymaps Editor</h1>
|
||||
<div class="contents">
|
||||
<div id="details">
|
||||
<p>You can use the RetopoFlow Keymaps Editor to configure some of the keymaps for RetopoFlow.</p>
|
||||
</div>
|
||||
<div id="keymaps"></div>
|
||||
</div>
|
||||
<div id="keymapsystem-buttons">
|
||||
<button title="Save custom keymaps and close" on_mouseclick="ok()">OK</button>
|
||||
<button title="Ignore any custom keymaps changes made and close" on_mouseclick="cancel()">Cancel</button>
|
||||
<button title="Reset all keymaps to default" on_mouseclick="reset_all()">Reset All</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<div id='keymapsystem-cover'></div>
|
||||
|
||||
<dialog id='keymapconfig' class="framed">
|
||||
<h1>Config: <span id="edit-action">Action</span></h1>
|
||||
<div class="contents">
|
||||
<div class="collection" id="edit-key-mouse">
|
||||
<h1>Key or mouse interaction to start action</h1>
|
||||
<div class="contents">
|
||||
<table>
|
||||
<tr>
|
||||
<td>Keyboard</td>
|
||||
<td><label id="edit-key-label" on_mouseclick="edit_capture()" on_keypress="edit_capture_key(event)"><input id="edit-key" type="radio" name="edit-key-mouse" value="key" title="Click to start capturing a key for action"><span id="edit-key-span" title="Click to start capturing a key for action"></span></label></td>
|
||||
</tr><tr>
|
||||
<td>Mouse Button</td>
|
||||
<td>
|
||||
<label id="edit-lmb-label"><input id="edit-lmb" type="radio" name="edit-key-mouse" value="lmb" on_mouseclick="edit_capture_mouse('LEFTMOUSE')" title="Click to use left mouse button (LMB) for action">Left (LMB)</label>
|
||||
<label id="edit-mmb-label"><input id="edit-mmb" type="radio" name="edit-key-mouse" value="mmb" on_mouseclick="edit_capture_mouse('MIDDLEMOUSE')" title="Click to use middle mouse button (MMB) for action">Middle (MMB)</label>
|
||||
<label id="edit-rmb-label"><input id="edit-rmb" type="radio" name="edit-key-mouse" value="rmb" on_mouseclick="edit_capture_mouse('RIGHTMOUSE')" title="Click to use right mouse button (RMB) for action">Right (RMB)</label>
|
||||
</td>
|
||||
</tr><tr>
|
||||
<td>Mouse Wheel</td>
|
||||
<td>
|
||||
<label id="edit-wu-label"><input id="edit-wu" type="radio" name="edit-key-mouse" value="wu" on_mouseclick="edit_capture_mouse('WHEELUPMOUSE')" title="Click to use mouse wheel up for action">Wheel Up</label>
|
||||
<label id="edit-wd-label"><input id="edit-wd" type="radio" name="edit-key-mouse" value="wd" on_mouseclick="edit_capture_mouse('WHEELDOWNMOUSE')" title="Click to use mouse wheel down for action">Wheel Down</label>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection" id="edit-modifiers">
|
||||
<h1>Interaction modifiers</h1>
|
||||
<div class="contents">
|
||||
<label><input id="edit-ctrl" type="checkbox" value="ctrl" title="Check if control key (^) should be held for action"><!-- ^ Ctrl --></label>
|
||||
<label><input id="edit-shift" type="checkbox" value="shift" title="Check if shift key (⇧) should be held for action"><!-- ⇧ Shift --></label>
|
||||
<label><input id="edit-alt" type="checkbox" value="alt" title="Check if alt (⌥ / option) key should be held for action"><!-- ⌥ Alt --></label>
|
||||
<label><input id="edit-oskey" type="checkbox" value="oskey" title="Check if oskey (⌘ / command) key should be held for action"><!-- ⌘ Cmd --></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection" id="edit-type">
|
||||
<h1>Interaction type</h1>
|
||||
<div class="contents">
|
||||
<label><input id="edit-press" type="radio" name="edit-type" value="press" title="Check if action occurs on press of key/mouse">Press</label>
|
||||
<label><input id="edit-release" type="radio" name="edit-type" value="release" title="Check if action occurs on release of key/mouse (not supported at the moment)" disabled>Release</label>
|
||||
<label><input id="edit-click" type="radio" name="edit-type" value="click" title="Check if action occurs on press/click of key/mouse">Click</label>
|
||||
<label><input id="edit-double" type="radio" name="edit-type" value="double" title="Check if action occurs on double press/click of key/mouse">Double</label>
|
||||
<label><input id="edit-drag" type="radio" name="edit-type" value="drag" title="Check if action occurs on drag of key/mouse">Drag</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="config-buttons">
|
||||
<button on_mouseclick="edit_ok()" title="Click to commit changes for this interaction">OK</button>
|
||||
<button on_mouseclick="edit_cancel()" title="Click to cancel changes for this interaction">Cancel</button>
|
||||
<button on_mouseclick="edit_delete()" title="Click to delete this interaction">Delete</button>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,4 @@
|
||||
<dialog class="framed" id="loadingdialog">
|
||||
<h1>Loading RetopoFlow...</h1>
|
||||
<article id="loadingdiv" class="mdown">Loading...</article>
|
||||
</dialog>
|
||||
@@ -0,0 +1,28 @@
|
||||
<dialog class="framed minimizeable moveable" id="maindialog" on_visibilitychange="self.update_main_ui_window()">
|
||||
<h1>RetopoFlow <script>write(retopoflow_product['version'])</script></h1>
|
||||
<div class="contents">
|
||||
<div id="tools"></div>
|
||||
<details id='help-buttons'>
|
||||
<summary>Documentation</summary>
|
||||
<div class="contents">
|
||||
<button title='Show the "Welcome!" message from the RetopoFlow team' on_mouseclick="self.helpsystem_open('welcome.md')">Welcome!</button>
|
||||
<button title='Show help table of contents ({humanread("all help")})' on_mouseclick="self.helpsystem_open('table_of_contents.md')">Table of Contents</button>
|
||||
<button title='Show how to get started with RetopoFlow' on_mouseclick="self.helpsystem_open('quick_start.md')">Quick Start Guide</button>
|
||||
<button title='Show general help ({humanread("general help")})' on_mouseclick="self.helpsystem_open('general.md')">General</button>
|
||||
<button title='Show help for currently selected tool ({humanread("tool help")})' on_mouseclick="self.helpsystem_open(self.rftool.help)">Active Tool</button>
|
||||
</div>
|
||||
</details>
|
||||
<!--<details>
|
||||
<summary>Test Alerts</summary>
|
||||
<div class="contents">
|
||||
<button title="Test note alert" on_mouseclick="self.alert_user(message='Foo', title='Test Alert', level='note')">Note</button>
|
||||
<button title="Test warning alert" on_mouseclick="self.alert_user(message='Foo', title='Test Alert', level='warning')">Warning</button>
|
||||
<button title="Test error alert" on_mouseclick="self.alert_user(message='Foo', title='Test Alert', level='error')">Error</button>
|
||||
<button title="Test assert alert" on_mouseclick="self.alert_user(message='Foo', title='Test Alert', level='assert')">Assert</button>
|
||||
<button title="Test exception alert" on_mouseclick="self.alert_user(message='Foo', title='Test Alert', level='exception')">Exception</button>
|
||||
</div>
|
||||
</details>-->
|
||||
<button title='Report an issue with RetopoFlow' on_mouseclick="bpy.ops.wm.url_open(url=retopoflow_urls['github issues'])">Report Issue</button>
|
||||
<button title='Quit RetopoFlow ({humanread("done")})' on_mouseclick="self.done()">Exit</button>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,4 @@
|
||||
<dialog class="framed minimizeable moveable" id="tinydialog" on_visibilitychange="self.update_tiny_ui_window()">
|
||||
<h1>RF</h1>
|
||||
<div class="contents" id="ttools"></div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,636 @@
|
||||
<script type="python">
|
||||
|
||||
def theme_change(e):
|
||||
if not e.target.checked: return
|
||||
if e.target.value is None: return
|
||||
options['color theme'] = e.target.value
|
||||
|
||||
def reset_options(self):
|
||||
options.reset()
|
||||
self.update_ui()
|
||||
self.document.body.dirty(children=True)
|
||||
|
||||
def reset_alpha_options(self):
|
||||
options.reset([
|
||||
'target alpha point',
|
||||
'target alpha point selected',
|
||||
'target alpha point warning',
|
||||
'target alpha point mirror',
|
||||
'target alpha point mirror selected',
|
||||
'target alpha point mirror warning',
|
||||
'target alpha point highlight',
|
||||
'target alpha line',
|
||||
'target alpha line selected',
|
||||
'target alpha line warning',
|
||||
'target alpha line mirror',
|
||||
'target alpha line mirror selected',
|
||||
'target alpha line mirror warning',
|
||||
'target alpha poly',
|
||||
'target alpha poly selected',
|
||||
'target alpha poly warning',
|
||||
'target alpha poly mirror',
|
||||
'target alpha poly mirror selected',
|
||||
'target alpha poly mirror warning',
|
||||
], version=False)
|
||||
self.update_ui()
|
||||
self.document.body.dirty(children=True)
|
||||
|
||||
|
||||
def update_clip_distances(self, *, rescale=True):
|
||||
def show_ui_if(ui_id, cond):
|
||||
ui = self.ui_options.getElementById(ui_id)
|
||||
ui.style = 'display:block' if cond else 'display:none'
|
||||
ui.dirty()
|
||||
ui.dirty_flow()
|
||||
self.update_clip_settings(rescale=rescale)
|
||||
ratio = options['clip auto end max'] / options['clip auto start min']
|
||||
show_ui_if('clip-auto-ratio-warning', ratio > 500000)
|
||||
# print(ratio)
|
||||
|
||||
show_ui_if('clip-auto-options', options['clip auto adjust'] == True)
|
||||
show_ui_if('clip-options', options['clip auto adjust'] == False)
|
||||
show_ui_if('fixed-clipping-distances', options['clip override'] == True)
|
||||
show_ui_if('scaled-clipping-distances', options['clip override'] == False)
|
||||
|
||||
def update_hide_overlays(self):
|
||||
if options['hide overlays']:
|
||||
self.overlays_hide()
|
||||
else:
|
||||
self.overlays_restore()
|
||||
|
||||
def mirror_viz_change(e):
|
||||
if not e.target.checked: return
|
||||
options['symmetry view'] = e.target.value
|
||||
|
||||
def mirror_changed(self):
|
||||
if not hasattr(self, 'ui_options'): return
|
||||
s = []
|
||||
if self.rftarget.mirror_mod.x: s += ['X']
|
||||
if self.rftarget.mirror_mod.y: s += ['Y']
|
||||
if self.rftarget.mirror_mod.z: s += ['Z']
|
||||
if not s: s = ['(none)']
|
||||
innerText = f'Symmetry: {",".join(s)}'
|
||||
ui = self.ui_options.getElementById('mirroroptions_summary')
|
||||
if ui.innerText == innerText: return
|
||||
ui.innerText = innerText
|
||||
self.check_target_symmetry()
|
||||
self.dirty_render()
|
||||
|
||||
</script>
|
||||
|
||||
<dialog class="framed moveable minimizeable" id="optionsdialog-minimized" on_toggle="self.restore_options_window(event.target)">
|
||||
<h1>Options</h1>
|
||||
</dialog>
|
||||
|
||||
<dialog id="optionsdialog" class="framed minimizeable moveable" on_toggle="self.minimize_options_window(event.target)">
|
||||
<h1>Options</h1>
|
||||
<div class="contents" id='options-contents'>
|
||||
<details id="generaloptions">
|
||||
<summary title="General options">General</summary>
|
||||
<div class="contents">
|
||||
<details>
|
||||
<summary title="These options control what runs when RetopoFlow starts and quits">Start Up and Quit</summary>
|
||||
<div class="contents">
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['welcome']''')" title="If enabled, the welcome message is displayed when RetopoFlow starts">
|
||||
Show Welcome on Start
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['check auto save']''')" title="If enabled, check if Auto Save is disabled at start">
|
||||
Check Auto Save on Start
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['check unsaved']''')" title="If enabled, check if blend file is unsaved at start">
|
||||
Check Unsaved on Start
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['confirm tab quit']''')" title="Check to confirm quitting when pressing Tab">
|
||||
Confirm Quit on Tab
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['escape to quit']''')" title="Check to allow Esc key to quit RetopoFlow">
|
||||
Escape to Quit
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['hide cursor on tweak']''')" title="Check to hide cursor when moving geometry">
|
||||
Hide Cursor on Grab
|
||||
</label>
|
||||
<div class="collection">
|
||||
<h1>Rip Automerge</h1>
|
||||
<div class="contents">
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['automerge']''')" title="If enabled, grabbed vertices automatically merged with nearby vertices">
|
||||
Enable Automerge
|
||||
</label>
|
||||
<div class="labeled-input-text">
|
||||
<label for="merge-distance">Merge distance</label>
|
||||
<input id="merge-distance" type="number" value="BoundInt( '''options['merge dist'] ''')" title="Pixel distance for merging and snapping">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1 title="These options control the RetopoFlow UI">Interface</h1>
|
||||
<div class="contents">
|
||||
<div class="labeled-input-text">
|
||||
<label for="interface-ui-scale">UI Scale</label>
|
||||
<input id="interface-ui-scale" type="number" value="BoundFloat('''options['ui scale']''', min_value=0.25, max_value=4)" title="Custom UI scaling setting">
|
||||
</div>
|
||||
<label>
|
||||
<input type="checkbox" checked="self._var_auto_hide_options" title="If enabled, options for selected tool will show while other tool options hide.">
|
||||
Auto Hide Tool Options
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['show tooltips']''')" title="Check to show tooltips">
|
||||
Show Tooltips
|
||||
</label>
|
||||
<div class="labeled-input-text">
|
||||
<label for="interface-tooltips-delay">Tooltips Delay</label>
|
||||
<input id="interface-tooltips-delay" type="number" value="BoundFloat('''options['tooltip delay']''', min_value=0.0)" title="Set delay before tooltips show">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details>
|
||||
<summary>Debugging</summary>
|
||||
<div class="contents">
|
||||
<div id='fpsdiv'>FPS: 0</div>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''self.cc_debug_all_enabled''')" title="Check to print all debugging info to text block">
|
||||
Print All
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''self.cc_debug_actions_enabled''')" title="Check to print (most) input actions to text block">
|
||||
Print Actions
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
<button title="Reset RetopoFlow back to factory settings" on_mouseclick="reset_options(self)">Reset All Settings</button>
|
||||
</div>
|
||||
</details>
|
||||
<details id="display-options">
|
||||
<summary title="Display options, such as shading theme, selection theme, and clipping.">Viewport Display</summary>
|
||||
<div class="contents">
|
||||
<div class="collection">
|
||||
<h1>Viewport Settings</h1>
|
||||
<div class="contents">
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['hide overlays']''')" on_input="update_hide_overlays(self)" title="If enabled, overlays (source wireframes, grid, axes, etc.) are hidden.">
|
||||
Hide Overlays
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['hide header panel']''')" title="If enabled, header panel of 3D Viewport is hidden when starting RetopoFlow.">
|
||||
Hide Header
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['hide panels no overlap']''')" title="If enabled, panels of 3D Viewport are hidden when starting RetopoFlow with region overlap disabled. Enabling this option can cause a slight jump when starting and ending RetopoFlow.">
|
||||
Hide Panels With No Region Overlap
|
||||
</label>
|
||||
<div class="collection">
|
||||
<h1>Optimize Shading</h1>
|
||||
<div class="contents">
|
||||
<label class="third-size">
|
||||
<input type="radio" name="shading-light" value="light" checked="BoundString('''options['override shading']''')" on_input="self.blender_shading_update()" title="Override shading settings for better performance and use a light matcap">
|
||||
Light
|
||||
</label>
|
||||
<label class="third-size">
|
||||
<input type="radio" name="shading-dark" value="dark" checked="BoundString('''options['override shading']''')" on_input="self.blender_shading_update()" title="Override shading settings for better performance and use a dark matcap">
|
||||
Dark
|
||||
</label>
|
||||
<label class="third-size">
|
||||
<input type="radio" name="shading-custom" value="off" checked="BoundString('''options['override shading']''')" on_input="self.blender_shading_update()" title="Do not override shading">
|
||||
Off
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Selection Theme</h1>
|
||||
<div class="contents">
|
||||
<label class="third-size">
|
||||
<input type="radio" id='theme-color-green' name="theme-color" value="Green" checked="BoundString('''options['color theme']''')" on_input="theme_change(event)" title="Draw the target mesh using a green theme.">
|
||||
Green
|
||||
</label>
|
||||
<label class="third-size">
|
||||
<input type="radio" id='theme-color-blue' name="theme-color" value="Blue" checked="BoundString('''options['color theme']''')" on_input="theme_change(event)" title="Draw the target mesh using a blue theme.">
|
||||
Blue
|
||||
</label>
|
||||
<label class="third-size">
|
||||
<input type="radio" id='theme-color-orange' name="theme-color" value="Orange" checked="BoundString('''options['color theme']''')" on_input="theme_change(event)" title="Draw the target mesh using a orange theme.">
|
||||
Orange
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Brush Settings</h1>
|
||||
<div class="contents">
|
||||
<div class="labeled-input-text">
|
||||
<label for="brush-min-alpha">Min Alpha</label>
|
||||
<input id="brush-min-alpha" type="number" value="BoundFloat('''options['brush min alpha']''', min_value=0.0, max_value=1.0)" title="Minimum alpha for Tweak and Relax brushes">
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="brush-max-alpha">Max Alpha</label>
|
||||
<input id="brush-max-alpha" type="number" value="BoundFloat('''options['brush max alpha']''', min_value=0.0, max_value=1.0)" title="Minimum alpha for Tweak and Relax brushes">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>View Clipping</h1>
|
||||
<div id="view-clipping-options" class="contents" on_visibilitychange="update_clip_distances(self, rescale=False)">
|
||||
<label>
|
||||
<input type="checkbox" id="clip-auto-adjust" checked="BoundBool('''options['clip auto adjust']''')" on_input="update_clip_distances(self)" title="If enabled, clipping settings are automatically adjusted based on view distance and bounding box of sources">
|
||||
Automatically Adjust
|
||||
</label>
|
||||
<div id="clip-auto-options" class="show-hide">
|
||||
<div id="clip-auto-ratio-warning">
|
||||
WARNING: the ratio of End Maximum over Start Minimum is very large, which can result in odd "snapping" behavior when working inside or very close to the sources. Consider increasing Start Minimum or decreasing End Maximum.
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="clip-auto-start-mult">Start Factor</label>
|
||||
<input id="clip-auto-start-mult" type="number" value="BoundFloat('''options['clip auto start mult']''', min_value=0.0001)" on_input="update_clip_distances(self)" title="Multiplying factor for near clipping distance">
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="clip-auto-start-min">Start Minimum</label>
|
||||
<input id="clip-auto-start-min" type="number" value="BoundFloat('''options['clip auto start min']''', min_value=0.0005)" on_input="update_clip_distances(self)" title="Minimum value for near clipping distance">
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="clip-auto-end-mult">End Factor</label>
|
||||
<input id="clip-auto-end-mult" type="number" value="BoundFloat('''options['clip auto end mult']''', min_value=0.0001)" on_input="update_clip_distances(self)" title="Multiplying factor for far clipping distance">
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="clip-auto-end-max">End Maximum</label>
|
||||
<input id="clip-auto-end-max" type="number" value="BoundFloat('''options['clip auto end max']''', min_value=0.0005, max_value=10000)" on_input="update_clip_distances(self)" title="Maximum value for far clipping distance">
|
||||
</div>
|
||||
</div>
|
||||
<div id="clip-options" class="show-hide">
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['clip override']''')" on_input="update_clip_distances(self)" title="If enabled, clipping distances will be set to fixed override values rather than scaled.">
|
||||
Override with Fixed Values
|
||||
</label>
|
||||
<div id="scaled-clipping-distances" class="show-hide">
|
||||
<div class="labeled-input-text">
|
||||
<label for="clip-start-scaled">Start (scaled)</label>
|
||||
<input id="clip-start-scaled" type="number" value="BoundFloat('''self.actions.space.clip_start''', min_value=0)" title="Near clipping distance (scaled)">
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="clip-end-scaled">End (scaled)</label>
|
||||
<input id="clip-end-scaled" type="number" value="BoundFloat('''self.actions.space.clip_end''', min_value=0)" title="Far clipping distance (scaled)">
|
||||
</div>
|
||||
</div>
|
||||
<div id="fixed-clipping-distances" class="show-hide">
|
||||
<div class="labeled-input-text">
|
||||
<label for="clip-start-fixed">Start</label>
|
||||
<input id="clip-start-fixed" type="number" value="BoundFloat('''options['clip start override']''', min_value=0)" on_input="update_clip_distances(self)" title="Near clipping distance (fixed)">
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="clip-end-fixed">End</label>
|
||||
<input id="clip-end-fixed" type="number" value="BoundFloat('''options['clip end override']''', min_value=0)" on_input="update_clip_distances(self)" title="Far clipping distance (fixed)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Target Mesh Drawing</h1>
|
||||
<div class="contents">
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['warn non-manifold']''')" title="Visualize non-manifold/detached vertices and edges">
|
||||
Non-manifold Outline
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['show pinned']''')" title="Visualize pinned geometry (vertices, edges, polygons). Note: to avoid confusion, pinning is disabled if not showing pinned geometry">
|
||||
Pinned Geometry
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['show seam']''')" title="Visualize seam edges. Note: to avoid confusion, seam pinning is disabled if not showing seam geometry">
|
||||
Seam Edges
|
||||
</label>
|
||||
<div class="labeled-input-text">
|
||||
<label for="target-draw-normal-offset">Normal Offset</label>
|
||||
<input id="target-draw-normal-offset" type="number" value="BoundFloat('''options['normal offset multiplier']''', min_value=0.0, max_value=2.0)" title="Sets how far geometry is pushed in visualization">
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="target-draw-alpha-above">Alpha Above</label>
|
||||
<input id="target-draw-alpha-above" type="number" value="BoundFloat('''options['target alpha']''', min_value=0.0, max_value=1.0)" title="Set transparency of target mesh that is above the source">
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="target-draw-alpha-below">Alpha Below</label>
|
||||
<input id="target-draw-alpha-below" type="number" value="BoundFloat('''options['target hidden alpha']''', min_value=0.0, max_value=1.0)" title="Set transparency of target mesh that is below the source">
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="target-draw-vertex-size">Vertex Size</label>
|
||||
<input id="target-draw-vertex-size" type="number" value="BoundFloat('''options['target vert size']''', min_value=0.1)" title="Draw radius of vertices.">
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="target-draw-edge-size">Edge Size</label>
|
||||
<input id="target-draw-edge-size" type="number" value="BoundFloat('''options['target edge size']''', min_value=0.1)" title="Draw width of edges.">
|
||||
</div>
|
||||
</div>
|
||||
<details>
|
||||
<summary>Individual Alpha Values</summary>
|
||||
<div class="contents">
|
||||
<div class="collection">
|
||||
<h1>Verts</h1>
|
||||
<div class="contents">
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-vert-normal">Normal</label>
|
||||
<input id="indiv-alpha-vert-normal" type="number" value="BoundFloat('''options['target alpha point']''', min_value=0.0, max_value=1.0)" title='Set transparency of normal target vertices'>
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-vert-selected">Selected</label>
|
||||
<input id="indiv-alpha-vert-selected" type="number" value="BoundFloat('''options['target alpha point selected']''', min_value=0.0, max_value=1.0)" title='Set transparency of selected target vertices'>
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-vert-warning">Warning</label>
|
||||
<input id="indiv-alpha-vert-warning" type="number" value="BoundFloat('''options['target alpha point warning']''', min_value=0.0, max_value=1.0)" title='Set transparency of warning target vertices'>
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-vert-mirror">Mirror</label>
|
||||
<input id="indiv-alpha-vert-mirror" type="number" value="BoundFloat('''options['target alpha point mirror']''', min_value=0.0, max_value=1.0)" title='Set transparency of mirrored target vertices'>
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-vert-mirror-selected">Mirror Selected</label>
|
||||
<input id="indiv-alpha-vert-mirror-selected" type="number" value="BoundFloat('''options['target alpha point mirror selected']''', min_value=0.0, max_value=1.0)" title='Set transparency of selected, mirrored target vertices'>
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-vert-mirror-warning">Mirror Warning</label>
|
||||
<input id="indiv-alpha-vert-mirror-warning" type="number" value="BoundFloat('''options['target alpha point mirror warning']''', min_value=0.0, max_value=1.0)" title='Set transparency of warning, mirrored target vertices'>
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-vert-highlight">Highlight</label>
|
||||
<input id="indiv-alpha-vert-highlight" type="number" value="BoundFloat('''options['target alpha point highlight']''', min_value=0.0, max_value=1.0)" title='Set transparency of highlighted target vertices'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Edges</h1>
|
||||
<div class="contents">
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-edge-normal">Normal</label>
|
||||
<input id="indiv-alpha-edge-normal" type="number" value="BoundFloat('''options['target alpha line']''', min_value=0.0, max_value=1.0)" title='Set transparency of normal target edges'>
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-edge-selected">Selected</label>
|
||||
<input id="indiv-alpha-edge-selected" type="number" value="BoundFloat('''options['target alpha line selected']''', min_value=0.0, max_value=1.0)" title='Set transparency of selected target edges'>
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-edge-warning">Warning</label>
|
||||
<input id="indiv-alpha-edge-warning" type="number" value="BoundFloat('''options['target alpha line warning']''', min_value=0.0, max_value=1.0)" title='Set transparency of warning target edges'>
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-edge-mirror">Mirror</label>
|
||||
<input id="indiv-alpha-edge-mirror" type="number" value="BoundFloat('''options['target alpha line mirror']''', min_value=0.0, max_value=1.0)" title='Set transparency of mirrored target edges'>
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-edge-mirror-selected">Mirror Selected</label>
|
||||
<input id="indiv-alpha-edge-mirror-selected" type="number" value="BoundFloat('''options['target alpha line mirror selected']''', min_value=0.0, max_value=1.0)" title='Set transparency of selected, mirrored target edges'>
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-edge-mirror-warning">Mirror Warning</label>
|
||||
<input id="indiv-alpha-edge-mirror-warning" type="number" value="BoundFloat('''options['target alpha line mirror warning']''', min_value=0.0, max_value=1.0)" title='Set transparency of warning, mirrored target edges'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Faces</h1>
|
||||
<div class="contents">
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-face-normal">Normal</label>
|
||||
<input id="indiv-alpha-face-normal" type="number" value="BoundFloat('''options['target alpha poly']''', min_value=0.0, max_value=1.0)" title='Set transparency of normal target faces'>
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-face-selected">Selected</label>
|
||||
<input id="indiv-alpha-face-selected" type="number" value="BoundFloat('''options['target alpha poly selected']''', min_value=0.0, max_value=1.0)" title='Set transparency of selected target faces'>
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-face-warning">Warning</label>
|
||||
<input id="indiv-alpha-face-warning" type="number" value="BoundFloat('''options['target alpha poly warning']''', min_value=0.0, max_value=1.0)" title='Set transparency of warning target faces'>
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-face-mirror">Mirror</label>
|
||||
<input id="indiv-alpha-face-mirror" type="number" value="BoundFloat('''options['target alpha poly mirror']''', min_value=0.0, max_value=1.0)" title='Set transparency of mirrored target faces'>
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-face-mirror-selected">Mirror Selected</label>
|
||||
<input id="indiv-alpha-face-mirror-selected" type="number" value="BoundFloat('''options['target alpha poly mirror selected']''', min_value=0.0, max_value=1.0)" title='Set transparency of selected, mirrored target faces'>
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="indiv-alpha-face-mirror-warning">Mirror Warning</label>
|
||||
<input id="indiv-alpha-face-mirror-warning" type="number" value="BoundFloat('''options['target alpha poly mirror warning']''', min_value=0.0, max_value=1.0)" title='Set transparency of warning, mirrored target faces'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button title="Reset individual alpha settings to default" on_mouseclick="reset_alpha_options(self)">Reset Individual Alpha Settings</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details id="selection-options">
|
||||
<summary title="Options for selection">Selection</summary>
|
||||
<div class="contents">
|
||||
<button title="Select everything connected to the current selection" on_mouseclick="self.select_linked()">Select Linked</button>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['selection occlusion test']''')" title="If enabled, target geometry that is occluded by source(s) are not selectable. Disable if a vertex is not selectable." on_change="self.get_accel_visible()">
|
||||
Block Occluded
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['selection backface test']''')" title="If enabled, geometry that is facing away is not selectable. Disable if a vertex is not selectable." on_change="self.get_accel_visible()">
|
||||
Block Backface
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['move rotate object if no selection']''')" title="If enabled, viewport orbitting will center on all sources when nothing is selected. Disable to keep orbit center at previous position.">
|
||||
Center Orbit on Sources
|
||||
</label>
|
||||
<details>
|
||||
<summary title="These options are used to tune the parameters for visibility testing">Advanced</summary>
|
||||
<div class="contents">
|
||||
<div class="labeled-input-text">
|
||||
<label for="advanced-bbox-factor">BBox factor</label>
|
||||
<input id="advanced-bbox-factor" type="number" value="BoundFloat('''options['visible bbox factor']''', min_value=0.0, max_value=1.0)" on_change="self.get_accel_visible()" title="Factor on minimum bounding box dimension that is added to max distance offset">
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="advanced-distance-offset">Distance offset</label>
|
||||
<input id="advanced-distance-offset" type="number" value="BoundFloat('''options['visible dist offset']''', min_value=0.0, max_value=1.0)" on_change="self.get_accel_visible()" title="Offsets target towards the camera when testing occlusion during selection. Increase this if a face appears unselectable">
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Presets</h1>
|
||||
<div class="contents">
|
||||
<button title="Preset options for working on tiny objects" class="half-size" on_mouseclick="self.visibility_preset_tiny()">Tiny</button>
|
||||
<button title="Preset options for working on normal-sized objects" class='half-size' on_mouseclick="self.visibility_preset_normal()">Normal</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
<details id='target-visibility-options'>
|
||||
<summary title="Hide/reveal target geometry">Target Visibility</summary>
|
||||
<div class="contents">
|
||||
<div class="collection">
|
||||
<h1>Hide</h1>
|
||||
<div class="contents">
|
||||
<button title="Hide all currently selected geometry ({humanread('hide selected')})" class="half-size" on_mouseclick="self.hide_selected()">Selected</button>
|
||||
<button title="Hide all currently unselected geometry ({humanread('hide unselected')})" class="half-size" on_mouseclick="self.hide_unselected()">Unselected</button>
|
||||
<br> <!-- this is a hack to make buttons below size correctly -->
|
||||
<button title="Hide all geometry currently visible" class="half-size" on_mouseclick="self.hide_visible()">Visible</button>
|
||||
<button title="Hide all geometry currently not visible (obscured, offscreen, etc.)" class="half-size" on_mouseclick="self.hide_nonvisible()">Non-visible</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Reveal</h1>
|
||||
<div class="contents">
|
||||
<button title="Reveal all geometry currently hidden ({humanread('reveal hidden')})" on_mouseclick="self.reveal_hidden()">Reveal Hidden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details id='target-cleaning-options'>
|
||||
<summary title="Clean up target geometry, such as snapping vertices to nearest point on source(s)">Target Cleaning</summary>
|
||||
<div class="contents">
|
||||
<div class="collection">
|
||||
<h1>Snap Vertices</h1>
|
||||
<div class="contents">
|
||||
<div class="labeled-input-text">
|
||||
<label for="push-snap-distance">Push Distance</label>
|
||||
<input id="push-snap-distance" type="number" value="BoundFloat('''options['push and snap distance']''', min_value=0)" title="Distance to push vertex out along normal before snapping back to source surface.">
|
||||
</div>
|
||||
<button title='Snap selected and non-hidden target vertices to nearest point on source(s).' classes="half-size" on_mouseclick="self.push_then_snap_selected_verts()">Selected</button>
|
||||
<button title='Snap all non-hidden target vertices to nearest point on source(s).' classes="half-size" on_mouseclick="self.push_then_snap_all_verts()">All</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Merge by Distance</h1>
|
||||
<div class="contents">
|
||||
<div class="labeled-input-text">
|
||||
<label for="merge-by-distance">Distance</label>
|
||||
<input id="merge-by-distance" type="number" value="BoundFloat('''options['remove doubles dist']''', min_value=0)" title='Distance within which vertices will be merged.'>
|
||||
</div>
|
||||
<button title='Merge selected vertices within given distance.' classes='half-size' on_mouseclick="self.remove_selected_doubles()">Selected</button>
|
||||
<button title='Merge all non-hidden vertices within given distance.' classes='half-size' on_mouseclick="self.remove_all_doubles()">All</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Normals</h1>
|
||||
<div class="contents">
|
||||
<button title="Flip normal of selected faces" class="half-size" on_mouseclick="self.flip_face_normals()">Flip</button>
|
||||
<button title="Recalculates the normal of selected faces, same as Recalculate Outside (Shift + N) in Edit Mode" class="half-size" on_mouseclick="self.recalculate_face_normals()">Recalculate</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Symmetry</h1>
|
||||
<div class="contents">
|
||||
<button title="Select vertices that are on the wrong side of symmetry plane" on_mouseclick="self.select_bad_symmetry()">Select Bad Symmetry</button>
|
||||
<button title="Flip vertices that are on the wrong side of symmetry plane" on_mouseclick="self.flip_symmetry_verts_to_correct_side()">Flip Bad Symmetry</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details id='target-pinning'>
|
||||
<summary title="Target pinning options">Pinning / Seams</summary>
|
||||
<div class="contents">
|
||||
<div class="collection">
|
||||
<h1>Geometry Pinning</h1>
|
||||
<div class="contents">
|
||||
<label class="pin-enable">
|
||||
<input type="checkbox" checked="BoundBool('''options['pin enabled']''')" title="Check to enable geometry pinning, where pinned geometry cannot be moved. Note: to avoid confusion, pinning is disabled if not showing pinned geometry">
|
||||
Enabled
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['show pinned']''')" title="Visualize pinned geometry (vertices, edges, polygons). Note: to avoid confusion, pinning is disabled if not showing pinned geometry">
|
||||
Show
|
||||
</label>
|
||||
<button title="Pin selected geometry" on_mouseclick="self.pin_selected()">Pin Selected</button>
|
||||
<button title="Unpin selected geometry" on_mouseclick="self.unpin_selected()" class="half-size">Unpin Selected</button>
|
||||
<button title="Unpin all geometry" on_mouseclick="self.unpin_all()" class="half-size">Unpin All</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Seams</h1>
|
||||
<div class="contents">
|
||||
<label class="pin-seams">
|
||||
<input type="checkbox" checked="BoundBool('''options['pin seam']''')" title="Check to pin seams, where seam edges cannot be moved. Note: to avoid confusion, seam pinning is disabled if not showing seams">
|
||||
Pin
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['show seam']''')" title="Visualize seam edges. Note: to avoid confusion, seam pinning is disabled if not showing seams">
|
||||
Show
|
||||
</label>
|
||||
<button title="Mark selected edges as seam" on_mouseclick="self.mark_seam_selected()" class="half-size">Mark Selected</button>
|
||||
<button title="Unmark selected edges as seam" on_mouseclick="self.clear_seam_selected()" class="half-size">Clear Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details id="mirror-options">
|
||||
<summary id="mirroroptions_summary" title="Mirroring options" on_load='mirror_changed(self)'>Symmetry</summary>
|
||||
<div class="contents">
|
||||
<label class="mirror-enable">
|
||||
<input type="checkbox" checked="BoundBool('''self.rftarget.mirror_mod.x''')" on_input="mirror_changed(self)" title="Check to mirror along x-axis">
|
||||
x
|
||||
</label>
|
||||
<label class="mirror-enable">
|
||||
<input type="checkbox" checked="BoundBool('''self.rftarget.mirror_mod.y''')" on_input="mirror_changed(self)" title="Check to mirror along y-axis">
|
||||
y
|
||||
</label>
|
||||
<label class="mirror-enable">
|
||||
<input type="checkbox" checked="BoundBool('''self.rftarget.mirror_mod.z''')" on_input="mirror_changed(self)" title="Check to mirror along z-axis">
|
||||
z
|
||||
</label>
|
||||
<div class="collection">
|
||||
<h1>Visualization</h1>
|
||||
<div class="contents">
|
||||
<label class='fourth-size'>
|
||||
<input type="radio" id='mirror-viz-none' name='mirror-viz' value='None' checked="BoundString('''options['symmetry view']''')" on_input="mirror_viz_change(event)" title='If checked, no mirror will be visualized, even if mirror is enabled (above). This setting will improve overall performance when working with very large source mesh(es).'>
|
||||
None
|
||||
</label>
|
||||
<label class='fourth-size'>
|
||||
<input type="radio" id="mirror-viz-plane" name="mirror-viz" value="Plane" checked="BoundString('''options['symmetry view']''')" on_input="mirror_viz_change(event)" title='If checked, mirror will be visualized by drawing the mirror planes as colored quads. This setting will improve overall performance when working with very large source mesh(es).'>
|
||||
Plane
|
||||
</label>
|
||||
<label class='fourth-size'>
|
||||
<input type="radio" id="mirror-viz-edge" name="mirror-viz" value="Edge" checked="BoundString('''options['symmetry view']''')" on_input="mirror_viz_change(event)" title='If checked, mirror will be visualized as a line, the intersection of the source meshes and the mirroring plane(s).'>
|
||||
Edge
|
||||
</label>
|
||||
<label class='fourth-size'>
|
||||
<input type="radio" id="mirror-viz-face" name="mirror-viz" value="Face" checked="BoundString('''options['symmetry view']''')" on_input="mirror_viz_change(event)" title='If checked, mirror will be visualized by coloring the mirrored side of source mesh(es).'>
|
||||
Face
|
||||
</label>
|
||||
<div class="labeled-input-text">
|
||||
<label for="visualization-source-effect">Source Effect</label>
|
||||
<input id="visualization-source-effect" type="number" value="BoundFloat('''options['symmetry effect']''', min_value=0.0, max_value=1.0)" title="Effect of mirror source visualization"> <!-- scrub -->
|
||||
</div>
|
||||
<input type="range" value="BoundFloat('''options['symmetry effect']''', min_value=0.0, max_value=1.0)">
|
||||
<div class="labeled-input-text">
|
||||
<label for="visualization-target-effect">Target Effect</label>
|
||||
<input id="visualization-target-effect" type="number" value="BoundFloat('''options['target alpha mirror']''', min_value=0.0, max_value=1.0)" title="Factor for alpha of mirrored target visualization"> <!-- scrub -->
|
||||
</div>
|
||||
<input type="range" value="BoundFloat('''options['target alpha mirror']''', min_value=0.0, max_value=1.0)">
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['symmetry mirror input']''')" title='If checked, input will be mirrored to "correct" side of symmetry. Otherwise it will be clamped or clipped.'>
|
||||
Mirror Input
|
||||
</label>
|
||||
<div class="collection">
|
||||
<h1>Merge / Apply</h1>
|
||||
<div class="contents">
|
||||
<div class="labeled-input-text">
|
||||
<label for="merge-apply-threshold">Threshold</label>
|
||||
<input id="merge-apply-threshold" type="number" value="BoundFloat('''self.rftarget.mirror_mod.symmetry_threshold''', min_value=0.0, step_size=0.00001)" title='Distance within which mirrored vertices will be merged'> <!-- scrub -->
|
||||
</div>
|
||||
<button title="Apply mirror symmetry to target mesh" on_mouseclick="self.apply_mirror_symmetry()">Apply Mirror</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Cleaning</h1>
|
||||
<div class="contents">
|
||||
<button title="Select vertices that are on the wrong side of symmetry plane" on_mouseclick="self.select_bad_symmetry()">Select Bad Symmetry</button>
|
||||
<button title="Flip vertices that are on the wrong side of symmetry plane" on_mouseclick="self.flip_symmetry_verts_to_correct_side()">Flip Bad Symmetry</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,3 @@
|
||||
<dialog id='pie-menu'>
|
||||
<div id='pie-menu-contents' class='contents'></div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,13 @@
|
||||
<dialog id="quitdialog" class="framed" style="width:200px" on_mouseleave="mouseleave_event()" on_keypress="key(event)">
|
||||
<h1>Quit RetopoFlow?</h1>
|
||||
<div class="contents">
|
||||
<div>
|
||||
<button class="half-size" on_mouseclick="self.done()">Yes (Enter)</button>
|
||||
<button class="half-size" on_mouseclick="hide_ui_quit()">No (Esc)</button>
|
||||
</div>
|
||||
<label title="Check to confirm quitting when pressing Tab">
|
||||
<input type="checkbox" checked="BoundBool('''options['confirm tab quit']''')">
|
||||
Confirm quit on Tab
|
||||
</label>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,42 @@
|
||||
<dialog id='updatersystem' class="updatersystem framed closeable" on_close="close()" on_keypress="key(event)">
|
||||
<h1>RetopoFlow Updater System</h1>
|
||||
<div class="contents" id='alert-contents'>
|
||||
<div id="select-version">
|
||||
<p>You can use the RetopoFlow Updater System to download and install a specific version of RetopoFlow.</p>
|
||||
<p>Current RetopoFlow Version: <span id="current-version"></span></p>
|
||||
<p>NOTE: Versions before 3.1.0 do NOT have the RetopoFlow Updater System.</p>
|
||||
<!-- <p>Staging folder: <button id="open-staging-folder" on_mouseclick="open_staging_folder()">Open</button></p>
|
||||
<code id="staging-folder"></code> -->
|
||||
<div class="collection">
|
||||
<h1>Choose one of the following versions, then click Install</h1>
|
||||
<div class="contents">
|
||||
<div id="version-options" class="options"></div>
|
||||
<div class="actions">
|
||||
<button class="half-size" title="Open updater staging folder" on_mouseclick="open_staging_folder()">Open Staging Folder</button>
|
||||
<button class="half-size" title="Install selected version" id='load-version' on_mouseclick="load()" disabled>Install</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="update-succeeded">
|
||||
<p>RetopoFlow has been successfully updated to version: <span id="new-version"></span></p>
|
||||
<p>Restart Blender to see the changes.</p>
|
||||
<div class="actions">
|
||||
<button title="Exit Blender" on_mouseclick="bpy.ops.wm.quit_blender()">Exit Blender</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="update-failed">
|
||||
<p>RetopoFlow failed to update to version: <span id="fail-version"></span></p>
|
||||
<p>Error Message: <span id="fail-message"></span></p>
|
||||
<div class="actions">
|
||||
<button class="third-size" title="Open updater staging folder" on_mouseclick="open_staging_folder()">Open Staging Folder</button>
|
||||
<button class="third-size" title="Try a different version" on_mouseclick="try_again()">Try again</button>
|
||||
<button class="third-size" title="Exit Blender" on_mouseclick="bpy.ops.wm.quit_blender()">Exit Blender</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="updatersystem-buttons">
|
||||
<button title="Click to open RetopoFlow Blender Market page" on_mouseclick="blendermarket()">Blender Market</button>
|
||||
<button title="Click to close this updater dialog" on_mouseclick="close()">Close (Esc)</button>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,82 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import os
|
||||
import bpy
|
||||
import glob
|
||||
import time
|
||||
|
||||
from .rf.rf_keymapsystem import RetopoFlow_KeymapSystem
|
||||
from .rf.rf_ui_alert import RetopoFlow_UI_Alert
|
||||
|
||||
from ..addon_common.common.globals import Globals
|
||||
from ..addon_common.common import ui_core
|
||||
from ..addon_common.common.useractions import ActionHandler
|
||||
from ..addon_common.common.fsm import FSM
|
||||
|
||||
from ..addon_common.cookiecutter.cookiecutter import CookieCutter
|
||||
|
||||
from ..config.keymaps import get_keymaps
|
||||
from ..config.options import options
|
||||
|
||||
|
||||
class RetopoFlow_OpenKeymapSystem(CookieCutter, RetopoFlow_KeymapSystem, RetopoFlow_UI_Alert):
|
||||
@classmethod
|
||||
def can_start(cls, context):
|
||||
return True
|
||||
|
||||
def blender_ui_set(self):
|
||||
self.viewaa_simplify()
|
||||
# self.manipulator_hide()
|
||||
self.panels_hide()
|
||||
# self.overlays_hide()
|
||||
self.quadview_hide()
|
||||
self.region_darken()
|
||||
self.header_text_set('RetopoFlow Keymap Config System`')
|
||||
|
||||
def start(self):
|
||||
ui_core.ASYNC_IMAGE_LOADING = options['async image loading']
|
||||
|
||||
# preload_help_images.paused = True
|
||||
keymaps = get_keymaps(force_reload=True)
|
||||
self.actions = ActionHandler(self.context, keymaps)
|
||||
self.reload_stylings()
|
||||
self.blender_ui_set()
|
||||
self.keymap_config_open() #self.rf_startdoc, done_on_esc=True, closeable=True, on_close=self.done)
|
||||
Globals.ui_document.body.dirty(cause='changed document size', children=True)
|
||||
|
||||
def end(self):
|
||||
self._cc_blenderui_end()
|
||||
|
||||
# def update(self):
|
||||
# preload_help_images.paused = False
|
||||
|
||||
@FSM.on_state('main')
|
||||
def main(self):
|
||||
# print(f'Updater System main')
|
||||
if self.actions.pressed({'done', 'done alt0'}):
|
||||
self.done()
|
||||
return
|
||||
if self.actions.pressed({'F12'}):
|
||||
self.reload_stylings()
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import os
|
||||
import bpy
|
||||
import glob
|
||||
import time
|
||||
import atexit
|
||||
|
||||
from .rf.rf_blender_objects import RetopoFlow_Blender_Objects
|
||||
from .rf.rf_blender_save import RetopoFlow_Blender_Save
|
||||
from .rf.rf_drawing import RetopoFlow_Drawing
|
||||
from .rf.rf_fsm import RetopoFlow_FSM
|
||||
from .rf.rf_grease import RetopoFlow_Grease
|
||||
from .rf.rf_helpsystem import RetopoFlow_HelpSystem
|
||||
from .rf.rf_instrument import RetopoFlow_Instrumentation
|
||||
from .rf.rf_normalize import RetopoFlow_Normalize
|
||||
from .rf.rf_piemenu import RetopoFlow_PieMenu
|
||||
from .rf.rf_sources import RetopoFlow_Sources
|
||||
from .rf.rf_spaces import RetopoFlow_Spaces
|
||||
from .rf.rf_target import RetopoFlow_Target
|
||||
from .rf.rf_tools import RetopoFlow_Tools
|
||||
from .rf.rf_ui import RetopoFlow_UI
|
||||
from .rf.rf_ui_alert import RetopoFlow_UI_Alert
|
||||
from .rf.rf_undo import RetopoFlow_Undo
|
||||
from .rf.rf_updatersystem import RetopoFlow_UpdaterSystem
|
||||
|
||||
from ..addon_common.common.blender import (
|
||||
tag_redraw_all,
|
||||
get_path_from_addon_root,
|
||||
workspace_duplicate,
|
||||
show_error_message, BlenderPopupOperator, BlenderIcon,
|
||||
scene_duplicate,
|
||||
)
|
||||
from ..addon_common.common import ui_core
|
||||
from ..addon_common.common.decorators import add_cache
|
||||
from ..addon_common.common.debug import debugger
|
||||
from ..addon_common.common.drawing import Drawing, DrawCallbacks
|
||||
from ..addon_common.common.fontmanager import FontManager
|
||||
from ..addon_common.common.fsm import FSM
|
||||
from ..addon_common.common.globals import Globals
|
||||
from ..addon_common.common.image_preloader import ImagePreloader
|
||||
from ..addon_common.common.profiler import profiler
|
||||
from ..addon_common.common.utils import delay_exec, abspath, StopWatch
|
||||
from ..addon_common.common.ui_styling import load_defaultstylings
|
||||
from ..addon_common.common.ui_core import UI_Element
|
||||
from ..addon_common.common.useractions import ActionHandler
|
||||
from ..addon_common.cookiecutter.cookiecutter import CookieCutter
|
||||
|
||||
from ..config.keymaps import get_keymaps
|
||||
from ..config.options import options, sessionoptions
|
||||
|
||||
|
||||
class RetopoFlow(
|
||||
RetopoFlow_Blender_Objects,
|
||||
RetopoFlow_Blender_Save,
|
||||
RetopoFlow_Drawing,
|
||||
RetopoFlow_FSM,
|
||||
RetopoFlow_Grease,
|
||||
RetopoFlow_HelpSystem,
|
||||
RetopoFlow_Instrumentation,
|
||||
RetopoFlow_Normalize,
|
||||
RetopoFlow_PieMenu,
|
||||
RetopoFlow_Sources,
|
||||
RetopoFlow_Spaces,
|
||||
RetopoFlow_Target,
|
||||
RetopoFlow_Tools,
|
||||
RetopoFlow_UI,
|
||||
RetopoFlow_UI_Alert,
|
||||
RetopoFlow_Undo,
|
||||
RetopoFlow_UpdaterSystem,
|
||||
):
|
||||
|
||||
instance = None
|
||||
|
||||
@classmethod
|
||||
def can_start(cls, context):
|
||||
# check that the context is correct
|
||||
if not context.region or context.region.type != 'WINDOW': return False
|
||||
if not context.space_data or context.space_data.type != 'VIEW_3D': return False
|
||||
# check we are in mesh editmode
|
||||
if context.mode != 'EDIT_MESH': return False
|
||||
# make sure we are editing a mesh object
|
||||
ob = context.active_object
|
||||
if not ob or ob.type != 'MESH': return False
|
||||
if not ob.visible_get(): return False
|
||||
# make sure we have source meshes
|
||||
if not cls.get_sources(): return False
|
||||
# all seems good!
|
||||
return True
|
||||
|
||||
|
||||
# def prestart(self):
|
||||
# # duplicate workspace and scene so we can alter (most) settings without need to store/restore
|
||||
# self.workspace = workspace_duplicate(name='RetopoFlow')
|
||||
# self.scene = scene_duplicate(name='RetopoFlow')
|
||||
|
||||
def start(self):
|
||||
sw = StopWatch()
|
||||
|
||||
RetopoFlow.instance = self
|
||||
|
||||
keymaps = get_keymaps(force_reload=True)
|
||||
self.actions = ActionHandler(self.context, keymaps)
|
||||
self.scene = self.context.scene
|
||||
self.view_layer = self.context.view_layer
|
||||
|
||||
print(f'RetopoFlow: Starting... keymaps and actions {sw.elapsed():0.2f}')
|
||||
|
||||
# start loading
|
||||
self.statusbar_text_set('RetopoFlow is loading...')
|
||||
|
||||
# DO THESE BEFORE SWITCHING TO OBJECT MODE BELOW AND BEFORE SETTING UP SOURCES AND TARGET!
|
||||
# we need to store which objects are sources and which is target
|
||||
self.mark_sources_target()
|
||||
|
||||
print(f'RetopoFlow: Starting... statusbar and marking {sw.elapsed():0.2f}')
|
||||
|
||||
ui_core.ASYNC_IMAGE_LOADING = options['async image loading']
|
||||
self.loading_done = False
|
||||
self.init_undo() # hack to work around issue #949
|
||||
|
||||
print(f'RetopoFlow: Starting... undo {sw.elapsed():0.2f}')
|
||||
|
||||
# self.store_window_state(self.actions.r3d, self.actions.space)
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
self.init_normalize() # get scaling factor to fit all sources into unit box
|
||||
|
||||
print(f'RetopoFlow: Starting... normalizing {sw.elapsed():0.2f}')
|
||||
|
||||
self.setup_ui_blender()
|
||||
print(f'RetopoFlow: Starting... Blender UI {sw.elapsed():0.2f}')
|
||||
self.reload_stylings()
|
||||
print(f'RetopoFlow: Starting... stylings {sw.elapsed():0.2f}')
|
||||
|
||||
# the rest of setup is handled in `loading` state
|
||||
self.fsm.force_set_state('loading')
|
||||
print(f'RetopoFlow: Starting Total: {sw.total_elapsed():0.2f}')
|
||||
|
||||
|
||||
def end(self):
|
||||
self.normal_check.stop()
|
||||
options.clear_callbacks()
|
||||
self.end_normalize(self.context)
|
||||
self.blender_ui_reset()
|
||||
self.undo_clear()
|
||||
self.done_target()
|
||||
self.done_sources()
|
||||
FontManager.unload_fontids()
|
||||
# one more toggle, because done_target() might push to target mesh
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
self.unmark_sources_target() # DO THIS AS ONE OF LAST
|
||||
sessionoptions.clear()
|
||||
RetopoFlow.instance = None
|
||||
|
||||
|
||||
|
||||
@FSM.on_state('loading', 'enter')
|
||||
def setup_next_stage_enter(self):
|
||||
win = UI_Element.fromHTMLFile(abspath('html/loading_dialog.html'))[0]
|
||||
self.document.body.append_child(win)
|
||||
|
||||
self._setup_data = {
|
||||
'timer': self.actions.start_timer(120),
|
||||
'ui_window': win,
|
||||
'ui_div': win.getElementById('loadingdiv'),
|
||||
'broken': False, # indicates if a setup stage broke
|
||||
'ui_drawn': False, # used to know when UI has been drawn
|
||||
'i_stage': -1, # which stage are we currently on?
|
||||
'stage_data': None, # which stage is to be run
|
||||
'stages': [
|
||||
('Pausing help image preloading', ImagePreloader.pause),
|
||||
('Setting up target mesh', self.setup_target),
|
||||
('Setting up source mesh(es)', self.setup_sources),
|
||||
('Setting up symmetry data structures', self.setup_sources_symmetry), # must be called after self.setup_target()!!
|
||||
('Setting up rotation target', self.setup_rotate_about_active),
|
||||
('Setting up RetopoFlow states', self.setup_states),
|
||||
('Setting up RetopoFlow tools', self.setup_rftools),
|
||||
('Setting up grease marks', self.setup_grease),
|
||||
('Setting up visualizations', self.setup_drawing),
|
||||
('Setting up user interface', self.setup_ui), # must be called after self.setup_target() and self.setup_rftools()!!
|
||||
('Setting up undo system', self.undo_clear), # must be called after self.setup_ui()!!
|
||||
('Checking auto save / save', self.check_auto_save_warnings),
|
||||
('Checking target symmetry', self.check_target_symmetry),
|
||||
('Loading welcome message', self.show_welcome_message),
|
||||
('Resuming help image preloading', ImagePreloader.resume),
|
||||
],
|
||||
}
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate('loading')
|
||||
def setup_next_stage_drawn(self):
|
||||
self._setup_data['ui_drawn'] = True
|
||||
tag_redraw_all('allow startup dialog to render')
|
||||
|
||||
@FSM.on_state('loading')
|
||||
def setup_next_stage(self):
|
||||
tag_redraw_all('allow startup dialog to render')
|
||||
d = self._setup_data
|
||||
|
||||
if d['broken']:
|
||||
self.done(cancel=True, emergency_bail=True)
|
||||
show_error_message(
|
||||
[
|
||||
'An unexpected error occurred while trying to start RetopoFlow.',
|
||||
'Please consider reporting this issue so that we can fix it.',
|
||||
BlenderPopupOperator('cgcookie.retopoflow_web_blendermarket', icon='URL'),
|
||||
BlenderPopupOperator('cgcookie.retopoflow_web_github_newissue', icon='URL'),
|
||||
],
|
||||
title='RetopoFlow Error',
|
||||
)
|
||||
return
|
||||
|
||||
if d['stage_data']:
|
||||
if not d['ui_drawn']:
|
||||
# UI has not been updated yet
|
||||
return
|
||||
stage_name, stage_fn = d['stage_data']
|
||||
d['stage_data'] = None
|
||||
start_time = time.time()
|
||||
try:
|
||||
print(f'RetopoFlow: {stage_name}')
|
||||
stage_fn()
|
||||
print(f' elapsed: {(time.time() - start_time):0.2f} secs')
|
||||
except Exception as e:
|
||||
print(f'RetopoFlow Exception: {e}')
|
||||
debugger.print_exception()
|
||||
d['broken'] = True
|
||||
return
|
||||
|
||||
d['i_stage'] += 1
|
||||
if d['i_stage'] == len(d['stages']):
|
||||
# done with all stages!
|
||||
print('RetopoFlow: done with start')
|
||||
self.loading_done = True
|
||||
self.fsm.force_set_state('main')
|
||||
self.document.body.delete_child(d['ui_window'])
|
||||
d['timer'].done()
|
||||
return
|
||||
|
||||
# start next stage
|
||||
stage_name, stage_fn = d['stages'][d['i_stage']]
|
||||
d['ui_drawn'] = False
|
||||
d['stage_data'] = (stage_name, stage_fn)
|
||||
d['ui_div'].set_markdown(mdown=stage_name)
|
||||
|
||||
|
||||
RetopoFlow.cc_debug_print_to = 'RetopoFlow_Debug'
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
__all__ = []
|
||||
@@ -0,0 +1,180 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import os
|
||||
import bpy
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from mathutils import Matrix, Vector
|
||||
from bpy_extras.object_utils import object_data_add
|
||||
|
||||
from ...config.options import (
|
||||
options,
|
||||
retopoflow_datablocks,
|
||||
retopoflow_product,
|
||||
)
|
||||
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.decorators import blender_version_wrapper
|
||||
from ...addon_common.common.blender import (
|
||||
set_object_selection,
|
||||
get_active_object, set_active_object,
|
||||
toggle_screen_header,
|
||||
toggle_screen_toolbar,
|
||||
toggle_screen_properties,
|
||||
toggle_screen_lastop,
|
||||
)
|
||||
from ...addon_common.common.blender_preferences import get_preferences
|
||||
from ...addon_common.common.maths import BBox
|
||||
from ...addon_common.common.debug import dprint
|
||||
|
||||
class RetopoFlow_Blender_Objects:
|
||||
@staticmethod
|
||||
def is_valid_source(o, *, test_poly_count=True, context=None):
|
||||
if not o: return False
|
||||
context = context or bpy.context
|
||||
mark = RetopoFlow_Blender_Objects.get_sources_target_mark(o)
|
||||
if mark is not None: return mark == 'source'
|
||||
# if o == get_active_object(): return False
|
||||
if o == context.edit_object: return False
|
||||
if type(o) is not bpy.types.Object: return False
|
||||
if type(o.data) is not bpy.types.Mesh: return False
|
||||
if not o.visible_get(): return False
|
||||
if test_poly_count and not o.data.polygons: return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def is_valid_target(o, *, ignore_edit_mode=False, context=None):
|
||||
if not o: return False
|
||||
context = context or bpy.context
|
||||
mark = RetopoFlow_Blender_Objects.get_sources_target_mark(o)
|
||||
if mark is not None: return mark == 'target'
|
||||
# if o != get_active_object(): return False
|
||||
if not ignore_edit_mode and o != context.edit_object: return False
|
||||
if not o.visible_get(): return False
|
||||
if type(o) is not bpy.types.Object: return False
|
||||
if type(o.data) is not bpy.types.Mesh: return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def has_valid_source():
|
||||
return any(RetopoFlow_Blender_Objects.is_valid_source(o) for o in bpy.context.scene.objects)
|
||||
|
||||
@staticmethod
|
||||
def has_valid_target():
|
||||
return RetopoFlow_Blender_Objects.get_target() is not None
|
||||
|
||||
@staticmethod
|
||||
def is_in_valid_mode():
|
||||
for area in bpy.context.screen.areas:
|
||||
if area.type != 'VIEW_3D': continue
|
||||
if area.spaces[0].local_view:
|
||||
# currently in local view
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def mark_sources_target():
|
||||
for obj in bpy.data.objects:
|
||||
if RetopoFlow_Blender_Objects.is_valid_source(obj):
|
||||
# set as source
|
||||
obj['RetopFlow'] = 'source'
|
||||
elif RetopoFlow_Blender_Objects.is_valid_target(obj):
|
||||
obj['RetopoFlow'] = 'target'
|
||||
else:
|
||||
obj['RetopoFlow'] = 'unused'
|
||||
|
||||
@staticmethod
|
||||
def unmark_sources_target():
|
||||
for obj in bpy.data.objects:
|
||||
if 'RetopoFlow' not in obj: continue
|
||||
del obj['RetopoFlow']
|
||||
|
||||
@staticmethod
|
||||
def any_marked_sources_target():
|
||||
return any('RetopoFlow' in obj for obj in bpy.data.objects)
|
||||
|
||||
@staticmethod
|
||||
def get_sources_target_mark(obj):
|
||||
if 'RetopoFlow' not in obj: return None
|
||||
return obj['RetopoFlow']
|
||||
|
||||
@staticmethod
|
||||
def get_sources(*, ignore_active=False):
|
||||
is_valid = RetopoFlow_Blender_Objects.is_valid_source
|
||||
active = bpy.context.active_object
|
||||
is_ignored = lambda o: (ignore_active and o == active)
|
||||
return [ o for o in bpy.data.objects if is_valid(o) and not is_ignored(o) ]
|
||||
|
||||
@staticmethod
|
||||
def get_target():
|
||||
is_valid = RetopoFlow_Blender_Objects.is_valid_target
|
||||
return next(( o for o in bpy.data.objects if is_valid(o) ), None)
|
||||
|
||||
@staticmethod
|
||||
def create_new_target(context, *, matrix_world=None):
|
||||
auto_edit_mode = bpy.context.preferences.edit.use_enter_edit_mode # working around blender bug, see https://github.com/CGCookie/retopoflow/issues/786
|
||||
bpy.context.preferences.edit.use_enter_edit_mode = False
|
||||
|
||||
for o in bpy.data.objects: o.select_set(False)
|
||||
|
||||
mesh = bpy.data.meshes.new('RetopoFlow')
|
||||
obj = object_data_add(context, mesh, name='RetopoFlow')
|
||||
|
||||
obj.select_set(True)
|
||||
context.view_layer.objects.active = obj
|
||||
|
||||
if matrix_world:
|
||||
obj.matrix_world = matrix_world
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
bpy.context.preferences.edit.use_enter_edit_mode = auto_edit_mode
|
||||
|
||||
|
||||
####################################################
|
||||
# methods for rotating about selection
|
||||
|
||||
def setup_rotate_about_active(self):
|
||||
self.end_rotate_about_active() # clear out previous rotate-about object
|
||||
auto_edit_mode = bpy.context.preferences.edit.use_enter_edit_mode # working around blender bug, see https://github.com/CGCookie/retopoflow/issues/786
|
||||
bpy.context.preferences.edit.use_enter_edit_mode = False
|
||||
o = object_data_add(bpy.context, None, name=retopoflow_datablocks['rotate object'])
|
||||
bpy.context.preferences.edit.use_enter_edit_mode = auto_edit_mode
|
||||
o.select_set(True)
|
||||
o.scale = Vector((0.01, 0.01, 0.01))
|
||||
bpy.context.view_layer.objects.active = o
|
||||
self.update_rot_object()
|
||||
|
||||
@staticmethod
|
||||
def end_rotate_about_active(*, reset_active=True):
|
||||
# IMPORTANT: changes here should also go in rf_blender_save.backup_recover()
|
||||
name = retopoflow_datablocks['rotate object']
|
||||
if name not in bpy.data.objects: return
|
||||
is_active = (bpy.context.view_layer.objects.active == bpy.data.objects[name])
|
||||
# delete rotate object
|
||||
bpy.data.objects.remove(bpy.data.objects[name], do_unlink=True)
|
||||
if is_active and reset_active:
|
||||
bpy.context.view_layer.objects.active = RetopoFlow_Blender_Objects.get_target()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import os
|
||||
import bpy
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from itertools import chain
|
||||
|
||||
from mathutils import Matrix, Vector
|
||||
from bpy_extras.object_utils import object_data_add
|
||||
from bpy.app.handlers import persistent
|
||||
|
||||
from ...config.options import options, sessionoptions, retopoflow_datablocks
|
||||
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.boundvar import BoundBool
|
||||
from ...addon_common.common.decorators import blender_version_wrapper
|
||||
from ...addon_common.common.blender import (
|
||||
set_object_selection,
|
||||
set_active_object,
|
||||
toggle_screen_header,
|
||||
toggle_screen_toolbar,
|
||||
toggle_screen_properties,
|
||||
toggle_screen_lastop,
|
||||
show_error_message,
|
||||
BlenderSettings,
|
||||
get_view3d_space,
|
||||
)
|
||||
from ...addon_common.common.blender_preferences import get_preferences
|
||||
from ...addon_common.common.maths import BBox
|
||||
from ...addon_common.common.debug import dprint
|
||||
|
||||
from .rf_blender_objects import RetopoFlow_Blender_Objects
|
||||
|
||||
|
||||
@persistent
|
||||
def revert_auto_save_after_load(*_, **__):
|
||||
# remove recover handler
|
||||
bpy.app.handlers.load_post.remove(revert_auto_save_after_load)
|
||||
window = bpy.context.window
|
||||
screen = window.screen
|
||||
area = next((a for a in screen.areas if a.type == 'VIEW_3D'), None)
|
||||
assert area
|
||||
space_data = next((s for s in area.spaces if s.type == 'VIEW_3D'), None)
|
||||
assert space_data
|
||||
region = next((r for r in area.regions if r.type == 'WINDOW'), None)
|
||||
assert region
|
||||
with bpy.context.temp_override(window=window, screen=screen, area=area, space_data=space_data, region=region):
|
||||
RetopoFlow_Blender_Save.recovery_revert()
|
||||
|
||||
|
||||
class RetopoFlow_Blender_Save:
|
||||
'''
|
||||
backup / restore methods
|
||||
'''
|
||||
|
||||
@staticmethod
|
||||
def can_recover():
|
||||
if retopoflow_datablocks['rotate object'] in bpy.data.objects: return True
|
||||
if sessionoptions.has_active_session_data(): return True
|
||||
if RetopoFlow_Blender_Objects.any_marked_sources_target(): return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def recovery_revert():
|
||||
print('RetopoFlow: recovering from auto save')
|
||||
|
||||
# the rotate object should not exist, but just in case
|
||||
if retopoflow_datablocks['rotate object'] in bpy.data.objects:
|
||||
bpy.data.objects.remove(
|
||||
bpy.data.objects[retopoflow_datablocks['rotate object']],
|
||||
do_unlink=True,
|
||||
)
|
||||
|
||||
# restore blender settings
|
||||
if sessionoptions.has_session_data():
|
||||
data = dict(sessionoptions['blender'])
|
||||
print(f'RetopoFlow: Restoring Session Data')
|
||||
print(f' {data}')
|
||||
bs = BlenderSettings(init_storage=data)
|
||||
bs.restore_all()
|
||||
|
||||
space = get_view3d_space()
|
||||
r3d = space.region_3d
|
||||
normalize_opts = sessionoptions['normalize']
|
||||
# scale view
|
||||
orig_view = normalize_opts['view']
|
||||
r3d.view_distance = orig_view['distance']
|
||||
r3d.view_location = Vector(orig_view['location'])
|
||||
# scale clip start and end
|
||||
orig_clip = normalize_opts['clip distances']
|
||||
space.clip_start = orig_clip['start']
|
||||
space.clip_end = orig_clip['end']
|
||||
# scale meshes
|
||||
prev_factor = normalize_opts['mesh scaling factor']
|
||||
M = (Matrix.Identity(3) * (1.0 / prev_factor)).to_4x4()
|
||||
sources = RetopoFlow_Blender_Objects.get_sources()
|
||||
target = RetopoFlow_Blender_Objects.get_target()
|
||||
for obj in chain(sources, [target]):
|
||||
if not obj: continue
|
||||
obj.matrix_world = M @ obj.matrix_world
|
||||
|
||||
if target:
|
||||
try:
|
||||
# try to select object
|
||||
target.select_set(True)
|
||||
bpy.context.view_layer.objects.active = target
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
except:
|
||||
pass
|
||||
|
||||
sessionoptions.clear()
|
||||
|
||||
# unmark all objects
|
||||
RetopoFlow_Blender_Objects.unmark_sources_target()
|
||||
|
||||
# # grab previous blender state
|
||||
# if options['blender state'] in bpy.data.texts:
|
||||
# data = json.loads(bpy.data.texts[options['blender state']].as_string())
|
||||
|
||||
# # get target object and reset settings
|
||||
# tar_object = bpy.data.objects[data['active object']]
|
||||
# tar_object.hide_viewport = False
|
||||
# tar_object.hide_render = False
|
||||
# bpy.context.view_layer.objects.active = tar_object
|
||||
# tar_object.select_set(True)
|
||||
|
||||
# RetopoFlow_Normalize.end_normalize(bpy.context)
|
||||
|
||||
# bpy.data.texts.remove(
|
||||
# bpy.data.texts[options['blender state']],
|
||||
# do_unlink=True,
|
||||
# )
|
||||
|
||||
# restore window state (mostly tool, properties, header, etc.)
|
||||
# RetopoFlow_Blender_UI.restore_window_state(
|
||||
# ignore_panels=False,
|
||||
# ignore_mode=False,
|
||||
# )
|
||||
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_auto_save_settings(context):
|
||||
prefs = get_preferences(context)
|
||||
use_auto_save = prefs.filepaths.use_auto_save_temporary_files
|
||||
path_blend = getattr(bpy.data, 'filepath', '')
|
||||
path_autosave = options.get_auto_save_filepath()
|
||||
good_auto_save = (not options['check auto save']) or use_auto_save
|
||||
good_unsaved = (not options['check unsaved']) or path_blend
|
||||
return {
|
||||
'auto save': good_auto_save,
|
||||
'auto save path': path_autosave,
|
||||
'saved': good_unsaved,
|
||||
}
|
||||
|
||||
def check_auto_save_warnings(self):
|
||||
settings = RetopoFlow_Blender_Save.get_auto_save_settings(self.actions.context)
|
||||
save = self.actions.to_human_readable('blender save')
|
||||
good_auto_save = settings['auto save']
|
||||
path_autosave = settings['auto save path']
|
||||
good_unsaved = settings['saved']
|
||||
|
||||
if good_auto_save and good_unsaved: return
|
||||
|
||||
message = []
|
||||
|
||||
if not good_auto_save:
|
||||
opt_autosave = '''options['check auto save']'''
|
||||
message += ['\n'.join([
|
||||
'The Auto Save option in Blender (Edit > Preferences > Save & Load > Auto Save) is currently disabled.',
|
||||
'Your changes will _NOT_ be saved automatically!',
|
||||
'',
|
||||
'''<label><input type="checkbox" checked="BoundBool(opt_autosave)">Check Auto Save option when RetopoFlow starts</label>''',
|
||||
])]
|
||||
|
||||
if not good_unsaved:
|
||||
opt_unsaved = '''options['check unsaved']'''
|
||||
message += ['\n'.join([
|
||||
'You are currently working on an _UNSAVED_ Blender file.',
|
||||
f'Your changes will be saved to `{path_autosave}` when you press `{save}`',
|
||||
'',
|
||||
'''<label><input type="checkbox" checked="BoundBool(opt_unsaved)">Run check for unsaved .blend file when RetopoFlow starts</label>''',
|
||||
])]
|
||||
else:
|
||||
message += ['Press `%s` any time to save your changes.' % (save)]
|
||||
|
||||
self.alert_user(
|
||||
title='Blender auto save / save file checker',
|
||||
message='\n\n'.join(message),
|
||||
level='warning',
|
||||
)
|
||||
|
||||
def handle_auto_save(self):
|
||||
prefs = get_preferences(self.actions.context)
|
||||
use_auto_save = prefs.filepaths.use_auto_save_temporary_files
|
||||
auto_save_time = prefs.filepaths.auto_save_time * 60
|
||||
|
||||
if not use_auto_save: return # Blender's auto save is disabled :(
|
||||
|
||||
if not hasattr(self, 'time_to_save'):
|
||||
# RF just started, so do not save yet
|
||||
self.last_change_count = None
|
||||
# record the next time to save
|
||||
self.time_to_save = time.time() + auto_save_time
|
||||
elif time.time() > self.time_to_save:
|
||||
# it is time to save, but only if current tool is in main and changes were made!
|
||||
if self.rftool._fsm_in_main():
|
||||
if self.save_backup():
|
||||
# save was successful!
|
||||
# record the next time to save
|
||||
self.time_to_save = time.time() + auto_save_time
|
||||
else:
|
||||
# save was unsuccessful :(
|
||||
# try again in 10secs
|
||||
self.time_to_save = time.time() + 10
|
||||
|
||||
@staticmethod
|
||||
def has_auto_save():
|
||||
filepath = options['last auto save path']
|
||||
return filepath and os.path.exists(filepath)
|
||||
|
||||
@staticmethod
|
||||
def get_auto_save_filename():
|
||||
return options['last auto save path']
|
||||
|
||||
@staticmethod
|
||||
def recover_auto_save():
|
||||
filepath = options['last auto save path']
|
||||
print(f'backup recover: {filepath}')
|
||||
if not filepath or not os.path.exists(filepath):
|
||||
print(f' DOES NOT EXIST!')
|
||||
return
|
||||
bpy.app.handlers.load_post.append(revert_auto_save_after_load)
|
||||
bpy.ops.wm.open_mainfile(filepath=filepath)
|
||||
|
||||
@staticmethod
|
||||
def delete_auto_save():
|
||||
filepath = options['last auto save path']
|
||||
print(f'backup delete: {filepath}')
|
||||
if not filepath or not os.path.exists(filepath):
|
||||
print(f' DOES NOT EXIST!')
|
||||
return
|
||||
os.remove(filepath)
|
||||
|
||||
def save_emergency(self):
|
||||
try:
|
||||
filepath = options.get_auto_save_filepath(suffix='EMERGENCY', emergency=True)
|
||||
bpy.ops.wm.save_as_mainfile(
|
||||
filepath=filepath,
|
||||
compress=True, # write compressed file
|
||||
check_existing=False, # do not warn if file already exists
|
||||
copy=True, # does not make saved file active
|
||||
)
|
||||
except:
|
||||
self.done(emergency_bail=True)
|
||||
show_error_message(
|
||||
'\n'.join([
|
||||
'RetopoFlow crashed unexpectedly.',
|
||||
'Be sure to save your work, and report what happened so that we can try fixing it.',
|
||||
]),
|
||||
title='RetopoFlow Error',
|
||||
)
|
||||
|
||||
def save_backup(self):
|
||||
if hasattr(self, '_backup_broken'): return
|
||||
if self.last_change_count == self.change_count:
|
||||
print(f'RetopoFlow: skipping backup save (no changes detected)')
|
||||
return True
|
||||
|
||||
if not hasattr(self, '_backup_save_attempts'): self._backup_save_attempts = 0
|
||||
|
||||
filepath = options.get_auto_save_filepath()
|
||||
filepath1 = f'{filepath}1'
|
||||
|
||||
print(f'RetopoFlow: saving backup to {filepath}')
|
||||
errors = {}
|
||||
|
||||
if os.path.exists(filepath):
|
||||
if os.path.exists(filepath1):
|
||||
try:
|
||||
print(f' deleting old backup: {filepath1}')
|
||||
os.remove(filepath1)
|
||||
except Exception as e:
|
||||
print(f' caught exception: {e}')
|
||||
errors['delete old'] = e
|
||||
|
||||
try:
|
||||
print(f' renaming prev backup: {filepath1}')
|
||||
os.rename(filepath, filepath1)
|
||||
except Exception as e:
|
||||
print(f' caught exception: {e}')
|
||||
errors['rename prev'] = e
|
||||
|
||||
if 'rename prev' not in errors:
|
||||
try:
|
||||
print(f' saving...')
|
||||
bpy.ops.wm.save_as_mainfile(
|
||||
filepath=filepath,
|
||||
compress=True, # write compressed file
|
||||
check_existing=False, # do not warn if file already exists
|
||||
copy=True, # does not make saved file active
|
||||
)
|
||||
options['last auto save path'] = filepath
|
||||
self.last_change_count = self.change_count
|
||||
except Exception as e:
|
||||
print(f' caught exception: {e}')
|
||||
errors['saving'] = e
|
||||
else:
|
||||
'''
|
||||
skipping normal save, because we might lose data!
|
||||
'''
|
||||
errors['skipped save'] = 'error while trying to rename prev'
|
||||
|
||||
if not errors:
|
||||
# all went well!
|
||||
self._backup_save_attempts = 0
|
||||
return True
|
||||
|
||||
print(f' Something happened')
|
||||
print(f' {errors=}')
|
||||
|
||||
self._backup_save_attempts += 1
|
||||
if self._backup_save_attempts < 4:
|
||||
print(f' Trying again soon...')
|
||||
else:
|
||||
print(f' Alerting user...')
|
||||
self._backup_broken = True
|
||||
self.alert_user(
|
||||
title='Could not save backup',
|
||||
level='assert',
|
||||
message=(
|
||||
f'Could not save backup file. '
|
||||
f'Temporarily preventing further backup attempts. '
|
||||
f'You might try saving file manually.\n\n'
|
||||
f'File paths: `{filepath}`, `{filepath1}`\n\n'
|
||||
f'Errors: {errors}\n\n'
|
||||
),
|
||||
)
|
||||
return False
|
||||
|
||||
def save_normal(self):
|
||||
with self.blender_ui_pause():
|
||||
with sessionoptions.temp_disable():
|
||||
try:
|
||||
bpy.ops.wm.save_mainfile()
|
||||
except Exception as e:
|
||||
# could not save for some reason; let the artist know!
|
||||
self.alert_user(
|
||||
title='Could not save',
|
||||
message=f'Could not save blend file.\n\nError message: "{e}"',
|
||||
level='warning',
|
||||
)
|
||||
# note: filepath might not be set until after save
|
||||
filepath = os.path.abspath(bpy.data.filepath)
|
||||
print(f'RetopoFlow: saved to {filepath}')
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import os
|
||||
import bpy
|
||||
import math
|
||||
import time
|
||||
import urllib
|
||||
|
||||
import gpu
|
||||
from mathutils import Vector, Matrix
|
||||
from gpu_extras.presets import draw_texture_2d
|
||||
|
||||
from ...addon_common.cookiecutter.cookiecutter import CookieCutter
|
||||
|
||||
from ...addon_common.common import gpustate
|
||||
from ...addon_common.common.drawing import DrawCallbacks
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.debug import tprint
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.hasher import Hasher
|
||||
from ...addon_common.common.maths import Point, Point2D, Vec2D, XForm, clamp
|
||||
from ...addon_common.common.maths import matrix_normal, Direction
|
||||
from ...addon_common.terminal.term_printer import sprint
|
||||
from ...config.options import options, visualization
|
||||
|
||||
|
||||
class RetopoFlow_Drawing:
|
||||
def get_view_version(self):
|
||||
return Hasher(self.actions.r3d.view_matrix, self.actions.space.lens, self.actions.r3d.view_distance, self.actions.area.width, self.actions.area.height)
|
||||
|
||||
def setup_drawing(self):
|
||||
def callback():
|
||||
Globals.drawing.update_dpi()
|
||||
source_opts = visualization.get_source_settings()
|
||||
target_opts = visualization.get_target_settings()
|
||||
self.rftarget_draw.replace_opts(target_opts)
|
||||
# self.document.body.dirty(cause='--> options changed', children=True)
|
||||
for d in self.rfsources_draw: d.replace_opts(source_opts)
|
||||
options.add_callback(callback)
|
||||
self._draw_count = 0
|
||||
|
||||
@DrawCallbacks.on_predraw()
|
||||
def predraw(self):
|
||||
if not self.loading_done: return
|
||||
self.update(timer=False)
|
||||
self._draw_count += 1
|
||||
|
||||
def get_view_matrix(self):
|
||||
return self.actions.r3d.view_matrix
|
||||
|
||||
def get_projection_matrix(self):
|
||||
return None
|
||||
# r3d = self.actions.r3d
|
||||
# if r3d.view_perspective == 'ORTHO':
|
||||
# xdelta = right - left
|
||||
# ydelta = top - bottom
|
||||
# zdelta = farclip - nearclip
|
||||
# return Matrix([
|
||||
# [2 / xdelta, 0, 0, -(right + left) / xdelta],
|
||||
# [0, 2 / ydelta, 0, -(top + bottom) / ydelta],
|
||||
# [0, 0, -2 / zdelta, -(farclip + nearclip) / zdelta],
|
||||
# [0, 0, 0, 1],
|
||||
# ])
|
||||
# else:
|
||||
# return
|
||||
|
||||
# @DrawCallbacks.on_draw('post2d')
|
||||
def draw_selection_buffer(self):
|
||||
return
|
||||
if not self.scene.camera: return
|
||||
sprint(hasattr(self, '_gpuoffscreen'))
|
||||
if not hasattr(self, '_gpuoffscreen'):
|
||||
self._gpuoffscreen = gpu.types.GPUOffScreen(1, 1) #, format='RGBA8')
|
||||
self._gpuoffscreen_draw_count = -1
|
||||
if not hasattr(self, '_draw_count'):
|
||||
# setup not fully complete yet!
|
||||
return
|
||||
|
||||
if self._gpuoffscreen_draw_count == self._draw_count: return
|
||||
|
||||
w, h = int(self.actions.size.x), int(self.actions.size.y)
|
||||
if self._gpuoffscreen.width != w or self._gpuoffscreen.height != h:
|
||||
sprint(f'RESIZING')
|
||||
self._gpuoffscreen.free()
|
||||
self._gpuoffscreen = gpu.types.GPUOffScreen(w, h) #, format='RGBA8')
|
||||
|
||||
sprint(f'DRAWING GPUOFFSCREEN')
|
||||
self._gpuoffscreen_draw_count = self._draw_count
|
||||
print(self.scene.camera)
|
||||
print(self.scene.camera.matrix_world)
|
||||
|
||||
view_mat = self.scene.camera.matrix_world.inverted_safe()
|
||||
depsgraph = self.context.view_layer.depsgraph # self.context.evaluated_depsgraph_get()
|
||||
proj_mat = self.scene.camera.calc_matrix_camera(depsgraph, x=w, y=h)
|
||||
print(view_mat)
|
||||
print(proj_mat)
|
||||
with self._gpuoffscreen.bind():
|
||||
self._gpuoffscreen.draw_view3d(
|
||||
self.scene, # scene to draw
|
||||
self.view_layer, # view layer to draw
|
||||
self.actions.space, # 3D view to get the drawing settings from
|
||||
self.actions.region, # Region of the 3D view
|
||||
view_mat, # view matrix
|
||||
proj_mat, # projection matrix
|
||||
do_color_management=False,
|
||||
draw_background=False,
|
||||
)
|
||||
gpustate.depth_mask(False)
|
||||
gpustate.blend('ALPHA')
|
||||
draw_texture_2d(
|
||||
self._gpuoffscreen.texture_color,
|
||||
(0, 0),
|
||||
w, h,
|
||||
)
|
||||
|
||||
@DrawCallbacks.on_draw('post3d')
|
||||
def draw_target_and_sources(self):
|
||||
if not self.actions.r3d: return
|
||||
if not self.loading_done: return
|
||||
# if self.fps_low_warning: return # skip drawing if low FPS warning is showing
|
||||
|
||||
buf_matrix_target = self.rftarget_draw.rfmesh.xform.mx_p
|
||||
buf_matrix_target_inv = self.rftarget_draw.rfmesh.xform.imx_p
|
||||
buf_matrix_view = self.actions.r3d.view_matrix
|
||||
buf_matrix_view_invtrans = matrix_normal(self.actions.r3d.view_matrix)
|
||||
buf_matrix_proj = self.actions.r3d.window_matrix
|
||||
view_forward = self.Vec_forward()
|
||||
|
||||
gpustate.blend('ALPHA')
|
||||
|
||||
if options['symmetry view'] != 'None' and self.rftarget.mirror_mod.xyz:
|
||||
if options['symmetry view'] in {'Edge', 'Face'}:
|
||||
# get frame of target, used for symmetry decorations on sources
|
||||
ft = self.rftarget.get_frame()
|
||||
# render sources
|
||||
for rs,rfs in zip(self.rfsources, self.rfsources_draw):
|
||||
rfs.draw(
|
||||
view_forward, self.unit_scaling_factor,
|
||||
buf_matrix_target, buf_matrix_target_inv,
|
||||
buf_matrix_view, buf_matrix_view_invtrans, buf_matrix_proj,
|
||||
1.00, 0.05, # alpha above, alpha below
|
||||
False, 0.5, # cull backfaces, alpha_backfaces
|
||||
False, # draw mirrored
|
||||
symmetry=self.rftarget.mirror_mod.xyz,
|
||||
symmetry_view=options['symmetry view'],
|
||||
symmetry_effect=options['symmetry effect'],
|
||||
symmetry_frame=ft,
|
||||
)
|
||||
elif options['symmetry view'] == 'Plane':
|
||||
# draw symmetry planes
|
||||
gpustate.depth_test('LESS_EQUAL')
|
||||
gpustate.culling('NONE')
|
||||
drawing = Globals.drawing
|
||||
a = pow(options['symmetry effect'], 2.0) # fudge this value, because effect is different with plane than edge/face
|
||||
r = (1.0, 0.2, 0.2, a)
|
||||
g = (0.2, 1.0, 0.2, a)
|
||||
b = (0.2, 0.2, 1.0, a)
|
||||
w2l = self.rftarget_draw.rfmesh.xform.w2l_point
|
||||
l2w = self.rftarget_draw.rfmesh.xform.l2w_point
|
||||
# for rfs in self.rfsources:
|
||||
# corners = [self.Point_to_Point2D(l2w(p)) for p in rfs.get_local_bbox(w2l).corners]
|
||||
# drawing.draw2D_lines(corners, (1,1,1,1))
|
||||
corners = [ c for s in self.rfsources for c in s.get_local_bbox(w2l).corners ]
|
||||
mx, Mx = min(c.x for c in corners), max(c.x for c in corners)
|
||||
my, My = min(c.y for c in corners), max(c.y for c in corners)
|
||||
mz, Mz = min(c.z for c in corners), max(c.z for c in corners)
|
||||
cx, cy, cz = mx + (Mx - mx) / 2, my + (My - my) / 2, mz + (Mz - mz) / 2
|
||||
mx, Mx = cx + (mx - cx) * 1.2, cx + (Mx - cx) * 1.2
|
||||
my, My = cy + (my - cy) * 1.2, cy + (My - cy) * 1.2
|
||||
mz, Mz = cz + (mz - cz) * 1.2, cz + (Mz - cz) * 1.2
|
||||
if self.rftarget.mirror_mod.x:
|
||||
quad = [ l2w(Point((0, my, mz))), l2w(Point((0, my, Mz))), l2w(Point((0, My, Mz))), l2w(Point((0, My, mz))) ]
|
||||
drawing.draw3D_triangles([quad[0], quad[1], quad[2], quad[0], quad[2], quad[3]], [r, r, r, r, r, r])
|
||||
if self.rftarget.mirror_mod.y:
|
||||
quad = [ l2w(Point((mx, 0, mz))), l2w(Point((mx, 0, Mz))), l2w(Point((Mx, 0, Mz))), l2w(Point((Mx, 0, mz))) ]
|
||||
drawing.draw3D_triangles([quad[0], quad[1], quad[2], quad[0], quad[2], quad[3]], [g, g, g, g, g, g])
|
||||
if self.rftarget.mirror_mod.z:
|
||||
quad = [ l2w(Point((mx, my, 0))), l2w(Point((mx, My, 0))), l2w(Point((Mx, My, 0))), l2w(Point((Mx, my, 0))) ]
|
||||
drawing.draw3D_triangles([quad[0], quad[1], quad[2], quad[0], quad[2], quad[3]], [b, b, b, b, b, b])
|
||||
|
||||
# render target
|
||||
gpustate.blend('ALPHA')
|
||||
if True:
|
||||
alpha_above,alpha_below = options['target alpha'],options['target hidden alpha']
|
||||
cull_backfaces = options['target cull backfaces']
|
||||
alpha_backface = options['target alpha backface']
|
||||
self.rftarget_draw.draw(
|
||||
view_forward, self.unit_scaling_factor,
|
||||
buf_matrix_target, buf_matrix_target_inv,
|
||||
buf_matrix_view, buf_matrix_view_invtrans, buf_matrix_proj,
|
||||
alpha_above, alpha_below,
|
||||
cull_backfaces, alpha_backface,
|
||||
True, # draw_mirrored
|
||||
)
|
||||
|
||||
@DrawCallbacks.on_draw('post3d')
|
||||
def draw_greasemarks(self):
|
||||
return
|
||||
# if not self.actions.r3d: return
|
||||
# # THE FOLLOWING CODE NEEDS UPDATED TO NOT USE GLBEGIN!
|
||||
# # grease marks
|
||||
# b_g_l.glBegin(b_g_l.GL_QUADS)
|
||||
# for stroke_data in self.grease_marks:
|
||||
# b_g_l.glColor4f(*stroke_data['color'])
|
||||
# t = stroke_data['thickness']
|
||||
# s0,p0,n0,d0,d1 = None,None,None,None,None
|
||||
# for s1 in stroke_data['marks']:
|
||||
# p1,n1 = s1
|
||||
# if p0 and p1:
|
||||
# v01 = p1 - p0
|
||||
# if d0 is None: d0 = Direction(v01.cross(n0))
|
||||
# d1 = Direction(v01.cross(n1))
|
||||
# b_g_l.glVertex3f(*(p0-d0*t+n0*0.001))
|
||||
# b_g_l.glVertex3f(*(p0+d0*t+n0*0.001))
|
||||
# b_g_l.glVertex3f(*(p1+d1*t+n1*0.001))
|
||||
# b_g_l.glVertex3f(*(p1-d1*t+n1*0.001))
|
||||
# s0,p0,n0,d0 = s1,p1,n1,d1
|
||||
# b_g_l.glEnd()
|
||||
|
||||
|
||||
##################################
|
||||
# RFTool Drawing
|
||||
|
||||
@DrawCallbacks.on_draw('predraw')
|
||||
@FSM.onlyinstate({'main', 'quick switch'})
|
||||
def tool_new_frame(self):
|
||||
if not self.loading_done: return
|
||||
# if self.fsm.state == 'pie menu': return
|
||||
self.rftool._new_frame()
|
||||
|
||||
@DrawCallbacks.on_draw('pre3d')
|
||||
@FSM.onlyinstate({'main', 'quick switch'})
|
||||
def draw_tool_pre3d(self):
|
||||
if not self.loading_done: return
|
||||
# if self.fsm.state == 'pie menu': return
|
||||
self.rftool._draw_pre3d()
|
||||
|
||||
@DrawCallbacks.on_draw('post3d')
|
||||
@FSM.onlyinstate({'main', 'quick switch'})
|
||||
def draw_tool_post3d(self):
|
||||
if not self.loading_done: return
|
||||
# if self.fsm.state == 'pie menu': return
|
||||
self.rftool._draw_post3d()
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate({'main', 'quick switch'})
|
||||
def draw_tool_post2d(self):
|
||||
if not self.loading_done: return
|
||||
# if self.fsm.state == 'pie menu': return
|
||||
self.rftool._draw_post2d()
|
||||
|
||||
|
||||
#############################
|
||||
# RFWidget Drawing
|
||||
|
||||
@DrawCallbacks.on_draw('pre3d')
|
||||
@FSM.onlyinstate({'main', 'quick switch'})
|
||||
def draw_widget_pre3d(self):
|
||||
if not self.loading_done: return
|
||||
if not self.rftool.rfwidget: return
|
||||
if self._nav: return
|
||||
if self._hover_ui: return
|
||||
# if self.fsm.state == 'pie menu': return
|
||||
self.rftool.rfwidget._draw_pre3d()
|
||||
|
||||
@DrawCallbacks.on_draw('post3d')
|
||||
@FSM.onlyinstate({'main', 'quick switch'})
|
||||
def draw_widget_post3d(self):
|
||||
if not self.loading_done: return
|
||||
if not self.rftool.rfwidget: return
|
||||
if self._nav: return
|
||||
if self._hover_ui: return
|
||||
# if self.fsm.state == 'pie menu': return
|
||||
self.rftool.rfwidget._draw_post3d()
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate({'main', 'quick switch'})
|
||||
def draw_widget_post2d(self):
|
||||
if not self.loading_done: return
|
||||
if not self.rftool.rfwidget: return
|
||||
if self._nav: return
|
||||
if self._hover_ui: return
|
||||
# if self.fsm.state == 'pie menu': return
|
||||
self.rftool.rfwidget._draw_post2d()
|
||||
|
||||
@@ -0,0 +1,960 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import math
|
||||
import time
|
||||
import random
|
||||
from itertools import chain
|
||||
from functools import partial
|
||||
from collections import deque
|
||||
|
||||
from ..rfmesh.rfmesh_wrapper import RFVert, RFEdge, RFFace
|
||||
|
||||
from ...addon_common.common import gpustate
|
||||
from ...addon_common.cookiecutter.cookiecutter import CookieCutter
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.decorators import timed_call
|
||||
from ...addon_common.common.drawing import Cursors, DrawCallbacks, CC_DRAW, CC_2D_LINES
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.maths import Vec2D, Point2D, RelPoint2D, Direction2D
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.ui_core import UI_Element
|
||||
from ...addon_common.common.utils import normalize_triplequote, Dict
|
||||
from ...config.options import options, retopoflow_files
|
||||
from ...addon_common.common.timerhandler import StopwatchHandler, CallGovernor
|
||||
|
||||
class RetopoFlow_FSM(CookieCutter): # CookieCutter must be here in order to override fns
|
||||
def setup_states(self):
|
||||
self.view_version = None
|
||||
self._last_rfwidget = None
|
||||
self.fast_update_timer = self.actions.start_timer(120.0, enabled=False)
|
||||
|
||||
def update(self, timer=True):
|
||||
if not self.loading_done:
|
||||
# calling self.fsm.update() in case mouse is hovering over ui
|
||||
self.fsm.update()
|
||||
return
|
||||
|
||||
options.clean(raise_exception=False)
|
||||
if options.write_error and not hasattr(self, '_write_error_reported'):
|
||||
# could not write options to file for some reason
|
||||
# issue #1070
|
||||
self._write_error_reported = True
|
||||
message = normalize_triplequote(f'''
|
||||
Could not write options to file (incorrect permissions).
|
||||
|
||||
Check that you have permission to write to `{retopoflow_files["options filename"]}` to the RetopoFlow add-on folder.
|
||||
|
||||
Or, try: uninstall RetopoFlow from Blender, restart Blender, then install the latest version of RetopoFlow from the Blender Market.
|
||||
|
||||
Note: You can continue using RetopoFlow, but any changes to options will not be saved.
|
||||
This error will not be reported again during the current RetopoFlow session.
|
||||
''')
|
||||
self.alert_user(message, level='error')
|
||||
|
||||
if timer:
|
||||
self.rftool._callback('timer')
|
||||
if self.rftool.rfwidget:
|
||||
self.rftool.rfwidget._callback_widget('timer')
|
||||
|
||||
if self.rftool.rfwidget != self._last_rfwidget:
|
||||
# force redraw when widget changes to clear out any widget drawing
|
||||
self._last_rfwidget = self.rftool.rfwidget
|
||||
tag_redraw_all('RFWidget change')
|
||||
|
||||
rftarget_version = self.rftarget.get_version()
|
||||
if self.rftarget_version != rftarget_version:
|
||||
self.rftarget_version = rftarget_version
|
||||
self.update_rot_object()
|
||||
self.callback_target_change()
|
||||
tag_redraw_all('RF_FSM target change')
|
||||
|
||||
view_version = self.get_view_version()
|
||||
if self.view_version != view_version:
|
||||
self.update_view_sessionoptions(self.context)
|
||||
self.update_clip_settings(rescale=False)
|
||||
self.view_version = view_version
|
||||
self.callback_view_change()
|
||||
tag_redraw_all('RF_FSM view change')
|
||||
|
||||
self.actions.hit_pos,self.actions.hit_norm,_,_ = self.raycast_sources_mouse()
|
||||
fpsdiv = self.document.body.getElementById('fpsdiv')
|
||||
if fpsdiv: fpsdiv.innerText = f'UI FPS: {self.document._draw_fps:.2f}'
|
||||
|
||||
# @CallGovernor.limit(fn_delay=lambda:options['target change delay'])
|
||||
def callback_target_change(self):
|
||||
# throttling this fn will cause target_change and draw callbacks to get out-of-sync
|
||||
# ex: contours depends on data collected in target change callback!
|
||||
self.rftool._callback('target change')
|
||||
if self.rftool.rfwidget:
|
||||
self.rftool.rfwidget._callback_widget('target change')
|
||||
self.update_ui_geometry()
|
||||
tag_redraw_all('RF_FSM target change')
|
||||
|
||||
@CallGovernor.limit(fn_delay=lambda:options['view change delay'])
|
||||
def callback_view_change(self):
|
||||
self.rftool._callback('view change')
|
||||
if self.rftool.rfwidget:
|
||||
self.rftool.rfwidget._callback_widget('view change')
|
||||
tag_redraw_all('RF_FSM view change')
|
||||
|
||||
def should_pass_through(self, context, event):
|
||||
return self.actions.using('blender passthrough')
|
||||
|
||||
@FSM.on_state('main')
|
||||
def modal_main(self):
|
||||
# if self.actions.just_pressed: print('modal_main', self.actions.just_pressed)
|
||||
if self.rftool._fsm_in_main() and (not self.rftool.rfwidget or self.rftool.rfwidget._fsm_in_main()):
|
||||
# handle exit
|
||||
if self.actions.pressed('done'):
|
||||
if options['confirm tab quit']:
|
||||
self.show_quit_dialog()
|
||||
else:
|
||||
self.done()
|
||||
return
|
||||
if options['escape to quit'] and self.actions.pressed('done alt0'):
|
||||
self.done()
|
||||
return
|
||||
|
||||
# handle help actions
|
||||
if self.actions.pressed('all help'):
|
||||
self.helpsystem_open('table_of_contents.md')
|
||||
return
|
||||
if self.actions.pressed('general help'):
|
||||
self.helpsystem_open('general.md')
|
||||
return
|
||||
if self.actions.pressed('tool help'):
|
||||
self.helpsystem_open(self.rftool.help)
|
||||
return
|
||||
|
||||
# user wants to save?
|
||||
if self.actions.pressed('blender save'):
|
||||
self.save_normal()
|
||||
return
|
||||
|
||||
# toggle ui
|
||||
if self.actions.pressed('toggle ui'):
|
||||
# hide ui if main (or minimized main, tiny) is visible
|
||||
ui_hide = self.ui_main.is_visible or self.ui_tiny.is_visible
|
||||
if ui_hide:
|
||||
self.ui_hide = True
|
||||
self.ui_main.is_visible = False
|
||||
self.ui_tiny.is_visible = False
|
||||
self.ui_options.is_visible = False
|
||||
self.ui_options_min.is_visible = False
|
||||
self.ui_geometry.is_visible = False
|
||||
self.ui_geometry_min.is_visible = False
|
||||
else:
|
||||
self.ui_main.is_visible = options['show main window']
|
||||
self.ui_tiny.is_visible = not options['show main window']
|
||||
self.ui_options.is_visible = options['show options window']
|
||||
self.ui_options_min.is_visible = not options['show options window']
|
||||
self.ui_geometry.is_visible = options['show geometry window']
|
||||
self.ui_geometry_min.is_visible = not options['show geometry window']
|
||||
self.ui_hide = False
|
||||
return
|
||||
|
||||
# handle pie menu
|
||||
if self.actions.pressed('pie menu'):
|
||||
self.show_pie_menu([
|
||||
{'text':rftool.name, 'image':rftool.icon, 'value':rftool}
|
||||
for rftool in self.rftools
|
||||
], self.select_rftool, highlighted=self.rftool)
|
||||
return
|
||||
|
||||
# debugging
|
||||
if False:
|
||||
if self.actions.pressed('SHIFT+F5'): breakit = 42 / 0
|
||||
if self.actions.pressed('SHIFT+F6'): assert False
|
||||
if self.actions.pressed('SHIFT+F7'): self.alert_user(message='Foo', level='exception', msghash='2ec5e386ae05c1abeb66dce8e1f1cb95')
|
||||
if self.actions.pressed('F7'):
|
||||
assert False, 'test exception throwing'
|
||||
# self.alert_user(title='Test', message='foo bar', level='warning', msghash=None)
|
||||
return
|
||||
# if self.actions.just_pressed: print('modal_main', self.actions.just_pressed)
|
||||
|
||||
# profiler
|
||||
if False:
|
||||
if self.actions.pressed('SHIFT+F10'):
|
||||
profiler.clear()
|
||||
return
|
||||
if self.actions.pressed('SHIFT+F11'):
|
||||
profiler.printout()
|
||||
self.document.debug_print()
|
||||
return
|
||||
|
||||
# reload CSS
|
||||
if self.actions.pressed('reload css'):
|
||||
print('RetopoFlow: Reloading stylings')
|
||||
self.reload_stylings()
|
||||
return
|
||||
|
||||
# handle tool switching
|
||||
for rftool in self.rftools:
|
||||
if rftool == self.rftool: continue
|
||||
if self.actions.pressed(rftool.shortcut):
|
||||
self.select_rftool(rftool)
|
||||
return
|
||||
if self.actions.pressed(rftool.quick_shortcut, unpress=False):
|
||||
self.quick_select_rftool(rftool)
|
||||
return 'quick switch'
|
||||
|
||||
# handle undo/redo
|
||||
if self.actions.pressed('blender undo'):
|
||||
self.undo_pop()
|
||||
if self.rftool: self.rftool._reset()
|
||||
return
|
||||
if self.actions.pressed('blender redo'):
|
||||
self.redo_pop()
|
||||
if self.rftool: self.rftool._reset()
|
||||
return
|
||||
|
||||
# handle general selection (each tool will handle specific selection / selection painting)
|
||||
if self.actions.pressed('select all'):
|
||||
# print('modal_main:selecting all toggle')
|
||||
self.undo_push('select all')
|
||||
self.select_toggle()
|
||||
return
|
||||
if self.actions.pressed('deselect all'):
|
||||
self.undo_push('deselect all')
|
||||
self.deselect_all()
|
||||
return
|
||||
if self.actions.pressed('select invert'):
|
||||
self.undo_push('select invert')
|
||||
self.select_invert()
|
||||
return
|
||||
if self.actions.pressed('select linked'):
|
||||
self.undo_push('select linked')
|
||||
self.select_linked()
|
||||
return
|
||||
if self.actions.pressed({'select linked mouse', 'deselect linked mouse'}, unpress=False):
|
||||
select = self.actions.pressed('select linked mouse')
|
||||
self.actions.unpress()
|
||||
bmv,_ = self.accel_nearest2D_vert(max_dist=options['select dist'])
|
||||
bme,_ = self.accel_nearest2D_edge(max_dist=options['select dist'])
|
||||
bmf,_ = self.accel_nearest2D_face(max_dist=options['select dist'])
|
||||
connected_to = bmv or bme or bmf
|
||||
if connected_to:
|
||||
self.undo_push('select linked mouse')
|
||||
self.select_linked(connected_to=connected_to, select=select)
|
||||
return
|
||||
|
||||
# hide/reveal
|
||||
if self.actions.pressed('hide selected'):
|
||||
self.hide_selected()
|
||||
return
|
||||
if self.actions.pressed('hide unselected'):
|
||||
self.hide_unselected()
|
||||
return
|
||||
if self.actions.pressed('reveal hidden'):
|
||||
self.reveal_hidden()
|
||||
return
|
||||
|
||||
# delete
|
||||
if self.actions.pressed('delete'):
|
||||
self.show_delete_dialog()
|
||||
return
|
||||
if self.actions.pressed('delete pie menu'):
|
||||
def callback(option):
|
||||
if not option: return
|
||||
self.delete_dissolve_collapse_option(option)
|
||||
self.show_pie_menu([
|
||||
('Delete Verts', ('Delete', 'Vertices')),
|
||||
('Delete Edges', ('Delete', 'Edges')),
|
||||
('Delete Faces', ('Delete', 'Faces')),
|
||||
('Dissolve Faces', ('Dissolve', 'Faces')),
|
||||
('Dissolve Edges', ('Dissolve', 'Edges')),
|
||||
('Dissolve Verts', ('Dissolve', 'Vertices')),
|
||||
('Merge at Center', ('Merge', 'At Center')),
|
||||
('Merge by Distance', ('Merge', 'By Distance')),
|
||||
# ('Collapse Edges & Faces', ('Collapse', 'Edges & Faces')),
|
||||
#'Dissolve Loops',
|
||||
], callback, release='delete pie menu', always_callback=True, rotate=-60)
|
||||
return
|
||||
|
||||
# merge
|
||||
if self.actions.pressed('merge'):
|
||||
self.show_merge_dialog()
|
||||
return
|
||||
|
||||
# smoothing
|
||||
if self.actions.pressed('smooth edge flow'):
|
||||
self.smooth_edge_flow(iterations=options['smooth edge flow iterations'])
|
||||
return
|
||||
|
||||
# pin/unpin
|
||||
if self.actions.pressed('pin'):
|
||||
self.pin_selected()
|
||||
return
|
||||
if self.actions.pressed('unpin'):
|
||||
self.unpin_selected()
|
||||
return
|
||||
if self.actions.pressed('unpin all'):
|
||||
self.unpin_all()
|
||||
return
|
||||
|
||||
# seams
|
||||
if self.actions.pressed('mark seam'):
|
||||
self.mark_seam_selected()
|
||||
return
|
||||
if self.actions.pressed('clear seam'):
|
||||
self.clear_seam_selected()
|
||||
return
|
||||
|
||||
return self.modal_main_rest()
|
||||
|
||||
def modal_main_rest(self):
|
||||
self.ignore_ui_events = False
|
||||
|
||||
self.normal_check() # this call is governed!
|
||||
|
||||
if not self.actions.is_navigating: self.rftool._done_navigating()
|
||||
|
||||
if self.rftool.rfwidget:
|
||||
Cursors.set(self.rftool.rfwidget.rfw_cursor)
|
||||
if self.rftool.rfwidget.redraw_on_mouse and self.actions.mousemove:
|
||||
tag_redraw_all('RFTool.RFWidget.redraw_on_mouse')
|
||||
ret = self.rftool.rfwidget._fsm_update()
|
||||
if self.fsm.is_state(ret):
|
||||
return ret
|
||||
if self.rftool.rfwidget._fsm.state != 'main':
|
||||
self.ignore_ui_events = True
|
||||
return
|
||||
|
||||
ret = self.rftool._fsm_update()
|
||||
if self.fsm.is_state(ret):
|
||||
self.ignore_ui_events = True
|
||||
return ret
|
||||
if self.fsm.state != 'main':
|
||||
self.ignore_ui_events = True
|
||||
|
||||
if not self.ignore_ui_events:
|
||||
self.handle_auto_save()
|
||||
|
||||
if self.actions.pressed('rotate'):
|
||||
return 'rotate selected'
|
||||
|
||||
if self.actions.pressed('scale'):
|
||||
return 'scale selected'
|
||||
|
||||
if self.actions.pressed('rip'):
|
||||
return self.rip(fill=False)
|
||||
if self.actions.pressed('rip fill'):
|
||||
return self.rip(fill=True)
|
||||
|
||||
@FSM.on_state('quick switch', 'enter')
|
||||
def quick_switch_enter(self):
|
||||
self._quick_switch_wait = 2
|
||||
|
||||
@FSM.on_state('quick switch')
|
||||
def quick_switch(self):
|
||||
self._quick_switch_wait -= 1
|
||||
if self.rftool._fsm.state == 'main' and (not self.rftool.rfwidget or self.rftool.rfwidget._fsm.state == 'main'):
|
||||
if self._quick_switch_wait < 0 and self.actions.released(self.rftool.quick_shortcut):
|
||||
return 'main'
|
||||
self.modal_main_rest()
|
||||
|
||||
@FSM.on_state('quick switch', 'exit')
|
||||
def quick_switch_exit(self):
|
||||
self.quick_restore_rftool()
|
||||
|
||||
|
||||
def setup_action(self, pt0, pt1, fn_callback, done_pressed=None, done_released=None, cancel_pressed=None):
|
||||
v01 = pt1 - pt0
|
||||
self.action_data = {
|
||||
'p0': pt0, 'p1': pt1, 'v01': v01,
|
||||
'fn callback': fn_callback,
|
||||
'done pressed': done_pressed, 'done released': done_released, 'cancel pressed': cancel_pressed,
|
||||
'val': lambda p: v01.dot(p - pt0),
|
||||
}
|
||||
return 'action handler'
|
||||
|
||||
@FSM.on_state('action handler', 'enter')
|
||||
def action_handler_enter(self):
|
||||
assert self.action_data
|
||||
self.undo_push('action handler')
|
||||
self.fast_update_timer.start()
|
||||
self.action_data['mouse'] = self.actions.mouse
|
||||
self.action_data['val start'] = self.action_data['val'](self.actions.mouse)
|
||||
|
||||
@FSM.on_state('action handler')
|
||||
def action_handler(self):
|
||||
d = self.action_data
|
||||
if self.actions.pressed(d['done pressed']) or self.actions.released(d['done released']):
|
||||
self.actions_data = None
|
||||
return 'main'
|
||||
if self.actions.released(d['cancel pressed']):
|
||||
self.undo_pop()
|
||||
self.dirty()
|
||||
return 'main'
|
||||
if not self.actions.mousemove: return
|
||||
val = self.action_data['val'](self.actions.mouse)
|
||||
self.action_data['fn callback'](val - self.action_data['val start'])
|
||||
self.dirty()
|
||||
|
||||
@FSM.on_state('action handler', 'exit')
|
||||
def action_handler_exit(self):
|
||||
self.fast_update_timer.stop()
|
||||
|
||||
|
||||
|
||||
@FSM.on_state('rotate selected', 'can enter')
|
||||
# @profiler.function
|
||||
def rotate_selected_canenter(self):
|
||||
if not self.get_selected_verts(): return False
|
||||
|
||||
@FSM.on_state('rotate selected', 'enter')
|
||||
def rotate_selected_enter(self):
|
||||
bmverts = self.get_selected_verts()
|
||||
opts = {}
|
||||
opts['bmverts'] = [(bmv, self.Point_to_Point2D(bmv.co)) for bmv in bmverts]
|
||||
opts['center'] = RelPoint2D.average(co for _,co in opts['bmverts'])
|
||||
opts['rotate_x'] = Direction2D(self.actions.mouse - opts['center'])
|
||||
opts['rotate_y'] = Direction2D((-opts['rotate_x'].y, opts['rotate_x'].x))
|
||||
opts['move_done_pressed'] = 'confirm'
|
||||
opts['move_done_released'] = None
|
||||
opts['move_cancelled'] = 'cancel'
|
||||
opts['mouselast'] = self.actions.mouse
|
||||
opts['mousedown'] = self.actions.mouse
|
||||
opts['lasttime'] = 0
|
||||
self.rotate_selected_opts = opts
|
||||
self.undo_push('rotate')
|
||||
|
||||
statusbar = self.substitute_keymaps('{{confirm}} Confirm\t{{cancel}} Cancel', wrap='', pre='', post=':', separator='/', onlyfirst=2)
|
||||
statusbar = statusbar.replace('\t', ' ')
|
||||
self.context.workspace.status_text_set(f'Rotating selected: {statusbar}')
|
||||
|
||||
self.fast_update_timer.start()
|
||||
self.split_target_visualization_selected()
|
||||
self.set_accel_defer(True)
|
||||
tag_redraw_all('rotate init')
|
||||
|
||||
@FSM.on_state('rotate selected')
|
||||
# @profiler.function
|
||||
def rotate_selected(self):
|
||||
opts = self.rotate_selected_opts
|
||||
if self.actions.pressed(opts['move_done_pressed']):
|
||||
return 'main'
|
||||
if self.actions.released(opts['move_done_released']):
|
||||
return 'main'
|
||||
if self.actions.pressed(opts['move_cancelled']):
|
||||
self.undo_cancel()
|
||||
self.actions.unuse(opts['move_done_released'], ignoremods=True, ignoremulti=True)
|
||||
return 'main'
|
||||
|
||||
if (self.actions.mouse - opts['mouselast']).length == 0: return
|
||||
if time.time() < opts['lasttime'] + 0.05: return
|
||||
opts['mouselast'] = self.actions.mouse
|
||||
opts['lasttime'] = time.time()
|
||||
|
||||
delta = Direction2D(self.actions.mouse - opts['center'])
|
||||
dx,dy = opts['rotate_x'].dot(delta),opts['rotate_y'].dot(delta)
|
||||
theta = math.atan2(dy, dx)
|
||||
|
||||
set2D_vert = self.set2D_vert
|
||||
for bmv,xy in opts['bmverts']:
|
||||
if not bmv.is_valid: continue
|
||||
dxy = xy - opts['center']
|
||||
nx = dxy.x * math.cos(theta) - dxy.y * math.sin(theta)
|
||||
ny = dxy.x * math.sin(theta) + dxy.y * math.cos(theta)
|
||||
nxy = Point2D((nx, ny)) + opts['center']
|
||||
set2D_vert(bmv, nxy)
|
||||
self.update_verts_faces(v for v,_ in opts['bmverts'])
|
||||
self.dirty()
|
||||
tag_redraw_all('rotate mouse move')
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate('rotate selected')
|
||||
def draw_rotate_post2d(self):
|
||||
opts = self.rotate_selected_opts
|
||||
|
||||
gpustate.blend('ALPHA')
|
||||
Globals.drawing.draw2D_line(
|
||||
opts['mousedown'],
|
||||
opts['center'],
|
||||
(0.1, 1.0, 1.0, 1.0), color1=(0.1, 1.0, 1.0, 0.0),
|
||||
width=2, stipple=[2, 2]
|
||||
)
|
||||
Globals.drawing.draw2D_line(
|
||||
opts['center'],
|
||||
self.actions.mouse,
|
||||
(1.0, 1.0, 0.1, 1.0), color1=(1.0, 1.0, 0.1, 0.0),
|
||||
width=2, stipple=[2, 2]
|
||||
)
|
||||
|
||||
|
||||
@FSM.on_state('rotate selected', 'exit')
|
||||
def rotate_selected_exit(self):
|
||||
self.fast_update_timer.stop()
|
||||
self.clear_split_target_visualization()
|
||||
self.set_accel_defer(False)
|
||||
self._update_rftool_ui()
|
||||
|
||||
|
||||
|
||||
|
||||
@FSM.on_state('scale selected', 'can enter')
|
||||
# @profiler.function
|
||||
def scale_selected_canenter(self):
|
||||
if not self.get_selected_verts(): return False
|
||||
|
||||
@FSM.on_state('scale selected', 'enter')
|
||||
def scale_selected_enter(self):
|
||||
bmverts = self.get_selected_verts()
|
||||
opts = {}
|
||||
opts['bmverts'] = [(bmv, self.Point_to_Point2D(bmv.co)) for bmv in bmverts]
|
||||
opts['center'] = RelPoint2D.average(co for _,co in opts['bmverts'])
|
||||
opts['start_dist'] = (self.actions.mouse - opts['center']).length
|
||||
opts['move_done_pressed'] = 'confirm'
|
||||
opts['move_done_released'] = None
|
||||
opts['move_cancelled'] = 'cancel'
|
||||
opts['mouselast'] = self.actions.mouse
|
||||
opts['mousedown'] = self.actions.mouse
|
||||
opts['lasttime'] = 0
|
||||
self.scale_selected_opts = opts
|
||||
self.undo_push('scale')
|
||||
|
||||
statusbar = self.substitute_keymaps('{{confirm}} Confirm\t{{cancel}} Cancel', wrap='', pre='', post=':', separator='/', onlyfirst=2)
|
||||
statusbar = statusbar.replace('\t', ' ')
|
||||
self.context.workspace.status_text_set(f'Scaling selected: {statusbar}')
|
||||
|
||||
self.fast_update_timer.start()
|
||||
self.split_target_visualization_selected()
|
||||
self.set_accel_defer(True)
|
||||
tag_redraw_all('scale init')
|
||||
|
||||
@FSM.on_state('scale selected')
|
||||
# @profiler.function
|
||||
def scale_selected(self):
|
||||
opts = self.scale_selected_opts
|
||||
if self.actions.pressed(opts['move_done_pressed']):
|
||||
return 'main'
|
||||
if self.actions.released(opts['move_done_released']):
|
||||
return 'main'
|
||||
if self.actions.pressed(opts['move_cancelled']):
|
||||
self.undo_cancel()
|
||||
self.actions.unuse(opts['move_done_released'], ignoremods=True, ignoremulti=True)
|
||||
return 'main'
|
||||
|
||||
if (self.actions.mouse - opts['mouselast']).length == 0: return
|
||||
if time.time() < opts['lasttime'] + 0.05: return
|
||||
opts['mouselast'] = self.actions.mouse
|
||||
opts['lasttime'] = time.time()
|
||||
|
||||
dist = (self.actions.mouse - opts['center']).length
|
||||
|
||||
set2D_vert = self.set2D_vert
|
||||
for bmv,xy in opts['bmverts']:
|
||||
if not bmv.is_valid: continue
|
||||
dxy = xy - opts['center']
|
||||
nxy = dxy * dist / opts['start_dist'] + opts['center']
|
||||
set2D_vert(bmv, nxy)
|
||||
self.update_verts_faces(v for v,_ in opts['bmverts'])
|
||||
self.dirty()
|
||||
tag_redraw_all('scale mouse move')
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate('scale selected')
|
||||
def draw_rotate_post2d(self):
|
||||
opts = self.scale_selected_opts
|
||||
|
||||
gpustate.blend('ALPHA')
|
||||
Globals.drawing.draw2D_line(
|
||||
opts['mousedown'],
|
||||
opts['center'],
|
||||
(0.1, 1.0, 1.0, 1.0), color1=(0.1, 1.0, 1.0, 0.0),
|
||||
width=2, stipple=[2, 2]
|
||||
)
|
||||
Globals.drawing.draw2D_line(
|
||||
opts['center'],
|
||||
self.actions.mouse,
|
||||
(1.0, 1.0, 0.1, 1.0), color1=(1.0, 1.0, 0.1, 0.0),
|
||||
width=2, stipple=[2, 2]
|
||||
)
|
||||
|
||||
|
||||
@FSM.on_state('scale selected', 'exit')
|
||||
def scale_selected_exit(self):
|
||||
self.fast_update_timer.stop()
|
||||
self.clear_split_target_visualization()
|
||||
self.set_accel_defer(False)
|
||||
self._update_rftool_ui()
|
||||
|
||||
|
||||
def select_path(self, bmelem_types, fn_filter_bmelem=None, kwargs_select=None, kwargs_filter=None, **kwargs):
|
||||
vis_accel = self.get_accel_visible()
|
||||
nearest2D_vert = self.accel_nearest2D_vert
|
||||
nearest2D_edge = self.accel_nearest2D_edge
|
||||
nearest2D_face = self.accel_nearest2D_face
|
||||
|
||||
kwargs_filter = kwargs_filter or {}
|
||||
def fn_filter(bmelem):
|
||||
if not bmelem: return False
|
||||
if not fn_filter_bmelem: return True
|
||||
return fn_filter_bmelem(bmelem, **kwargs_filter)
|
||||
def get_bmelem(*args, **kwargs):
|
||||
if 'vert' in bmelem_types:
|
||||
bmelem, _ = nearest2D_vert(*args, vis_accel=vis_accel, **kwargs)
|
||||
if fn_filter(bmelem): return bmelem
|
||||
if 'edge' in bmelem_types:
|
||||
bmelem, _ = nearest2D_edge(*args, vis_accel=vis_accel, **kwargs)
|
||||
if fn_filter(bmelem): return bmelem
|
||||
if 'face' in bmelem_types:
|
||||
bmelem, _ = nearest2D_face(*args, vis_accel=vis_accel, **kwargs)
|
||||
if fn_filter(bmelem): return bmelem
|
||||
return None
|
||||
|
||||
bmelem = get_bmelem(max_dist=options['select dist']) # find what's under the mouse
|
||||
if not bmelem:
|
||||
# print('found nothing under mouse')
|
||||
return # nothing there; leave!
|
||||
|
||||
bmelem_types = { RFVert: {'vert'}, RFEdge: {'edge'}, RFFace: {'face'} }[type(bmelem)]
|
||||
kwargs_select = kwargs_select or {}
|
||||
kwargs.update(kwargs_select)
|
||||
kwargs['only'] = False
|
||||
|
||||
# find all other visible elements
|
||||
vis_elems = self.accel_vis_verts | self.accel_vis_edges | self.accel_vis_faces
|
||||
|
||||
# walk from bmelem to all other connected visible geometry
|
||||
path = {}
|
||||
working = deque()
|
||||
working.append((bmelem, None))
|
||||
def add(o, bme):
|
||||
nonlocal vis_elems, path, working
|
||||
if o not in vis_elems or o in path: return
|
||||
if not fn_filter(o): return
|
||||
working.append((o, bme))
|
||||
closest = None
|
||||
while working:
|
||||
bme, from_bme = working.popleft()
|
||||
if bme in path: continue
|
||||
path[bme] = from_bme
|
||||
if bme.select:
|
||||
# found closest!
|
||||
closest = bme
|
||||
break
|
||||
if 'vert' in bmelem_types:
|
||||
for c in bme.link_edges:
|
||||
o = c.other_vert(bme)
|
||||
add(o, bme)
|
||||
if 'edge' in bmelem_types:
|
||||
for c in bme.verts:
|
||||
for o in c.link_edges:
|
||||
add(o, bme)
|
||||
if 'face' in bmelem_types:
|
||||
for c in bme.edges:
|
||||
for o in c.link_faces:
|
||||
add(o, bme)
|
||||
|
||||
if not closest:
|
||||
# print('could not find closest element')
|
||||
return
|
||||
|
||||
self.undo_push('select path')
|
||||
while closest:
|
||||
self.select(closest, **kwargs)
|
||||
closest = path[closest]
|
||||
|
||||
|
||||
def setup_smart_selection_painting(self, bmelem_types, *, use_select_tool=False, selecting=True, deselect_all=False, fn_filter_bmelem=None, kwargs_select=None, kwargs_deselect=None, kwargs_filter=None, **kwargs):
|
||||
vis_accel = self.get_accel_visible()
|
||||
nearest2D_vert = self.accel_nearest2D_vert
|
||||
nearest2D_edge = self.accel_nearest2D_edge
|
||||
nearest2D_face = self.accel_nearest2D_face
|
||||
|
||||
kwargs_filter = kwargs_filter or {}
|
||||
kwargs_select = kwargs_select or {}
|
||||
kwargs_deselect = kwargs_deselect or {}
|
||||
|
||||
def fn_filter(bmelem):
|
||||
if not bmelem: return False
|
||||
if not fn_filter_bmelem: return True
|
||||
return fn_filter_bmelem(bmelem, **kwargs_filter)
|
||||
def get_bmelem(*args, **kwargs):
|
||||
if 'vert' in bmelem_types:
|
||||
bmelem, _ = nearest2D_vert(*args, vis_accel=vis_accel, **kwargs)
|
||||
if fn_filter(bmelem): return bmelem
|
||||
if 'edge' in bmelem_types:
|
||||
bmelem, _ = nearest2D_edge(*args, vis_accel=vis_accel, **kwargs)
|
||||
if fn_filter(bmelem): return bmelem
|
||||
if 'face' in bmelem_types:
|
||||
bmelem, _ = nearest2D_face(*args, vis_accel=vis_accel, **kwargs)
|
||||
if fn_filter(bmelem): return bmelem
|
||||
return None
|
||||
|
||||
bmelem_first = get_bmelem(max_dist=options['select dist']) # find what's under the mouse
|
||||
if not bmelem_first:
|
||||
# nothing there; either leave or use select tool
|
||||
if not use_select_tool:
|
||||
return
|
||||
rftool_select = next(rftool for rftool in self.rftools if rftool.name=='Select')
|
||||
self.quick_select_rftool(rftool_select)
|
||||
rftool_select._callback('quickselect start')
|
||||
return 'quick switch'
|
||||
|
||||
bmelem_type, vis_elems = {
|
||||
RFVert: ('vert', self.accel_vis_verts),
|
||||
RFEdge: ('edge', self.accel_vis_edges),
|
||||
RFFace: ('face', self.accel_vis_faces),
|
||||
}[type(bmelem_first)]
|
||||
bmelem_types = { bmelem_type } # needed so get_bmelem returns correct type
|
||||
|
||||
selecting |= not bmelem_first.select # if not explicitly selecting, start selecting only if elem under mouse is not selected
|
||||
kwargs.update(kwargs_select if selecting else kwargs_deselect)
|
||||
if selecting: kwargs['only'] = False
|
||||
|
||||
# walk from bmelem_first to all other connected visible geometry
|
||||
path_to_first = {}
|
||||
working = deque()
|
||||
def add_to_working(from_bmelem, to_bmelem):
|
||||
if to_bmelem not in vis_elems or to_bmelem in path_to_first: return
|
||||
if not fn_filter(to_bmelem): return
|
||||
working.append((from_bmelem, to_bmelem))
|
||||
add_to_working(None, bmelem_first)
|
||||
while working:
|
||||
from_bmelem, bmelem = working.popleft()
|
||||
if bmelem in path_to_first: continue
|
||||
path_to_first[bmelem] = from_bmelem
|
||||
match bmelem_type:
|
||||
case 'vert':
|
||||
for edge in bmelem.link_edges:
|
||||
for vert in edge.verts:
|
||||
add_to_working(bmelem, vert)
|
||||
case 'edge':
|
||||
for vert in bmelem.verts:
|
||||
for edge in vert.link_edges:
|
||||
add_to_working(bmelem, edge)
|
||||
case 'face':
|
||||
for edge in bmelem.edges:
|
||||
for face in edge.link_faces:
|
||||
add_to_working(bmelem, face)
|
||||
|
||||
fn_select = partial((self.select if selecting else self.deselect), **kwargs)
|
||||
|
||||
self.selection_painting_opts = Dict(
|
||||
fn_get_bmelem = get_bmelem,
|
||||
path_to_first = path_to_first,
|
||||
fn_select = fn_select,
|
||||
previous_selection = [],
|
||||
last_bmelem = bmelem_first,
|
||||
)
|
||||
|
||||
self.undo_push('smart select' if selecting else 'smart deselect')
|
||||
if deselect_all: self.deselect_all()
|
||||
fn_select(bmelem_first)
|
||||
|
||||
return 'smart selection painting'
|
||||
|
||||
@FSM.on_state('smart selection painting', 'enter')
|
||||
def smart_selection_painting_enter(self):
|
||||
self.fast_update_timer.start()
|
||||
self.split_target_visualization_visible()
|
||||
self.set_accel_defer(True)
|
||||
|
||||
|
||||
@DrawCallbacks.on_draw('predraw')
|
||||
@FSM.onlyinstate('smart selection painting')
|
||||
def unpause_smart_selection_painting_update(self):
|
||||
self.smart_selection_painting_update.unpause()
|
||||
|
||||
@CallGovernor.limit(pause_after_call=True)
|
||||
def smart_selection_painting_update(self):
|
||||
opts = self.selection_painting_opts
|
||||
|
||||
bmelem = opts.fn_get_bmelem()
|
||||
if not bmelem or bmelem not in opts.path_to_first: return
|
||||
|
||||
# hovering over same bmelem
|
||||
if bmelem == opts.last_bmelem: return
|
||||
opts.last_bmelem = bmelem
|
||||
|
||||
# reset to previous selection
|
||||
for (bme, s) in opts.previous_selection: bme.select = s
|
||||
|
||||
# get bmelems from hovered back to first
|
||||
current_selection = []
|
||||
while bmelem:
|
||||
current_selection.append(bmelem)
|
||||
bmelem = opts.path_to_first[bmelem]
|
||||
opts.previous_selection = [(bmelem, bmelem.select) for bmelem in current_selection]
|
||||
opts.fn_select(current_selection)
|
||||
|
||||
|
||||
@FSM.on_state('smart selection painting')
|
||||
def smart_selection_painting(self):
|
||||
if self.actions.pressed('cancel'):
|
||||
self.undo_cancel()
|
||||
self.actions.unuse('select paint', ignoremods=True, ignoremulti=True)
|
||||
self.actions.unuse('select paint add', ignoremods=True, ignoremulti=True)
|
||||
return 'main'
|
||||
|
||||
if not self.actions.using({'select paint', 'select paint add'}, ignoremods=True):
|
||||
return 'main'
|
||||
|
||||
if self.actions.mousemove:
|
||||
self.smart_selection_painting_update()
|
||||
tag_redraw_all('RF selection_painting') # needed to force perform update
|
||||
|
||||
|
||||
@FSM.on_state('smart selection painting', 'exit')
|
||||
def smart_selection_painting_exit(self):
|
||||
self.selection_painting_opts = None
|
||||
self.fast_update_timer.stop()
|
||||
self.clear_split_target_visualization()
|
||||
self.set_accel_defer(False)
|
||||
|
||||
|
||||
def rip(self, *, fill=False):
|
||||
# find highest order geometry selected
|
||||
# - faces: error
|
||||
# - edges: for each selected edge, find nearest adjacent face to mouse cursor and rip edge from other face
|
||||
# - verts: for each selected vert, find nearest adjacent edge to mouse cursor and rip vert from faces not adjacent to that edge
|
||||
sel_verts, sel_edges, sel_faces = self.get_selected_geom()
|
||||
|
||||
if sel_faces:
|
||||
self.alert_user('Can only rip a single edge, but a face is selected')
|
||||
return
|
||||
|
||||
if not sel_edges and not sel_verts:
|
||||
self.alert_user('Can only rip a single edge, but none are selected')
|
||||
return
|
||||
|
||||
if sel_verts and not sel_edges:
|
||||
self.alert_user('Ripping vertices is not supported yet')
|
||||
return
|
||||
|
||||
if sel_edges and len(sel_edges) > 1:
|
||||
# a temporary limitation
|
||||
self.alert_user('Ripping more than one selected edge is not supported yet')
|
||||
return
|
||||
|
||||
if not sel_edges:
|
||||
self.alert_user('Must have exactly one edge selected at the moment')
|
||||
return
|
||||
|
||||
|
||||
# working with first selected edge (current implementation limitation)
|
||||
bme = next(iter(sel_edges))
|
||||
|
||||
adj_faces = set(bme.link_faces)
|
||||
if len(adj_faces) < 2:
|
||||
self.alert_user('Edge must have at least two adjacent faces')
|
||||
return
|
||||
|
||||
bmv0, bmv1 = bme.verts
|
||||
nearest_face, _ = self.accel_nearest2D_face(faces_only=adj_faces)
|
||||
other_face = next(iter({bmf for bmf in bme.link_faces if bmf != nearest_face}), None)
|
||||
|
||||
self.undo_push('rip edge')
|
||||
if True:
|
||||
bmv2 = bmv0.face_separate(nearest_face)
|
||||
bmv3 = bmv1.face_separate(nearest_face)
|
||||
move_verts = [bmv2, bmv3]
|
||||
else:
|
||||
bmv2 = bmv0.face_separate(other_face)
|
||||
bmv3 = bmv1.face_separate(other_face)
|
||||
move_verts = [bmv0, bmv1]
|
||||
self.select(move_verts, only=True)
|
||||
|
||||
if fill:
|
||||
# only implemented simple fill for now
|
||||
self.new_face([bmv0, bmv1, bmv3, bmv2])
|
||||
|
||||
# self.undo_push('move ripped edge')
|
||||
self.prep_move(
|
||||
bmverts=move_verts,
|
||||
action_confirm=(lambda: self.actions.pressed({'confirm', 'confirm drag'})),
|
||||
)
|
||||
return 'move'
|
||||
|
||||
|
||||
def prep_move(self, *, bmverts=None, action_confirm=None, action_cancel=None, defer_recomputing=True):
|
||||
Point_to_Point2D = self.Point_to_Point2D
|
||||
self.move_settings = Dict(
|
||||
bmverts_xys = [
|
||||
(bmv, xy)
|
||||
for bmv in (bmverts if bmverts is not None else self.get_selected_verts())
|
||||
if bmv and bmv.is_valid and (xy := Point_to_Point2D(bmv.co)) is not None
|
||||
],
|
||||
actions = Dict(
|
||||
confirm=action_confirm or (lambda: self.actions.pressed('confirm')),
|
||||
cancel=action_cancel or (lambda: self.actions.pressed('cancel')),
|
||||
),
|
||||
mousedown = self.actions.mouse,
|
||||
last_delta = None,
|
||||
vis_accel = self.get_accel_visible(selected_only=False),
|
||||
)
|
||||
self.move_settings.bmverts = [bmv for (bmv,_) in self.move_settings.bmverts_xys]
|
||||
|
||||
@FSM.on_state('move', 'enter')
|
||||
def move_enter(self):
|
||||
# if not self.move_done_released and options['hide cursor on tweak']: self.set_widget('hidden')
|
||||
if options['hide cursor on tweak']: Cursors.set('NONE')
|
||||
self.split_target_visualization_selected()
|
||||
self.fast_update_timer.start()
|
||||
self.set_accel_defer(True)
|
||||
|
||||
@FSM.on_state('move')
|
||||
def modal_move(self):
|
||||
if self.move_settings.actions['confirm']():
|
||||
if options['automerge']:
|
||||
self.merge_verts_by_dist(self.move_settings.bmverts, options['merge dist'])
|
||||
return 'main'
|
||||
|
||||
if self.move_settings.actions['cancel']():
|
||||
self.undo_cancel()
|
||||
return 'main'
|
||||
|
||||
delta = Vec2D(self.actions.mouse - self.move_settings.mousedown)
|
||||
if delta == self.move_settings.last_delta: return
|
||||
self.move_settings.last_delta = delta
|
||||
set2D_vert = self.set2D_vert
|
||||
|
||||
for bmv,xy in self.move_settings.bmverts_xys:
|
||||
xy_updated = xy + delta
|
||||
# check if xy_updated is "close" to any visible verts (in image plane)
|
||||
# if so, snap xy_updated to vert position (in image plane)
|
||||
if options['automerge']:
|
||||
bmv1,d = self.accel_nearest2D_vert(point=xy_updated, vis_accel=self.move_settings.vis_accel, max_dist=options['merge dist'])
|
||||
if bmv1 is None:
|
||||
set2D_vert(bmv, xy_updated)
|
||||
continue
|
||||
xy1 = self.Point_to_Point2D(bmv1.co)
|
||||
if not xy1:
|
||||
set2D_vert(bmv, xy_updated)
|
||||
continue
|
||||
set2D_vert(bmv, xy1)
|
||||
else:
|
||||
set2D_vert(bmv, xy_updated)
|
||||
|
||||
self.update_verts_faces(self.move_settings.bmverts)
|
||||
self.dirty()
|
||||
tag_redraw_all('move update')
|
||||
|
||||
@FSM.on_state('move', 'exit')
|
||||
def move_exit(self):
|
||||
self.fast_update_timer.stop()
|
||||
self.set_accel_defer(False)
|
||||
self.clear_split_target_visualization()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
|
||||
class RetopoFlow_Grease:
|
||||
def setup_grease(self):
|
||||
self.grease_marks = []
|
||||
@@ -0,0 +1,120 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import os
|
||||
import re
|
||||
import bpy
|
||||
|
||||
from ...addon_common.common.boundvar import BoundBool
|
||||
from ...addon_common.common.blender import get_path_from_addon_root
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.utils import delay_exec, abspath
|
||||
from ...addon_common.common.ui_styling import load_defaultstylings
|
||||
from ...addon_common.common.ui_core import UI_Element
|
||||
|
||||
from ...config.options import options, retopoflow_urls
|
||||
from ...config.keymaps import get_keymaps
|
||||
|
||||
class RetopoFlow_HelpSystem:
|
||||
@staticmethod
|
||||
def reload_stylings():
|
||||
load_defaultstylings()
|
||||
path = get_path_from_addon_root('config', 'ui.css')
|
||||
try:
|
||||
Globals.ui_draw.load_stylesheet(path)
|
||||
except AssertionError as e:
|
||||
# TODO: show proper dialog to user here!!
|
||||
print('could not load stylesheet "%s"' % path)
|
||||
print(e)
|
||||
Globals.ui_document.body.dirty(cause='Reloaded stylings', children=True)
|
||||
Globals.ui_document.body.dirty_styling()
|
||||
Globals.ui_document.body.dirty_flow()
|
||||
|
||||
def substitute_keymaps(self, mdown, wrap='`', pre='', post='', separator=', ', onlyfirst=None):
|
||||
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
|
||||
else: wrap_pre, wrap_post = wrap
|
||||
while True:
|
||||
m = re.search(r'{{(?P<action>[^}]+)}}', mdown)
|
||||
if not m: break
|
||||
action = { s.strip() for s in m.group('action').split(',') }
|
||||
sub = f'{pre}{wrap_pre}' + self.actions.to_human_readable(action, sep=f'{wrap_post}{separator}{wrap_pre}', onlyfirst=onlyfirst) + f'{wrap_post}{post}'
|
||||
mdown = mdown[:m.start()] + sub + mdown[m.end():]
|
||||
return mdown
|
||||
|
||||
def substitute_options(self, mdown, wrap='', pre='', post='', separator=', ', onlyfirst=None):
|
||||
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
|
||||
else: wrap_pre, wrap_post = wrap
|
||||
while True:
|
||||
m = re.search(r'{\[(?P<option>[^\]]+)\]}', mdown)
|
||||
if not m: break
|
||||
opts = { s.strip() for s in m.group('option').split(',') }
|
||||
sub = f'{pre}{wrap_pre}' + separator.join(str(options[opt]) for opt in opts) + f'{wrap_post}{post}'
|
||||
mdown = mdown[:m.start()] + sub + mdown[m.end():]
|
||||
return mdown
|
||||
|
||||
def substitute_python(self, mdown, wrap='', pre='', post=''):
|
||||
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
|
||||
else: wrap_pre, wrap_post = wrap
|
||||
while True:
|
||||
m = re.search(r'{`(?P<python>[^`]+)`}', mdown)
|
||||
if not m: break
|
||||
pyret = eval(m.group('python'), globals(), locals())
|
||||
sub = f'{pre}{wrap_pre}{pyret}{wrap_post}{post}'
|
||||
mdown = mdown[:m.start()] + sub + mdown[m.end():]
|
||||
return mdown
|
||||
|
||||
def helpsystem_open(self, mdown_path, done_on_esc=False, closeable=True, *args, **kwargs):
|
||||
ui_markdown = self.document.body.getElementById('helpsystem-mdown')
|
||||
if not ui_markdown:
|
||||
keymaps = get_keymaps()
|
||||
def close():
|
||||
nonlocal done_on_esc
|
||||
if done_on_esc:
|
||||
self.done()
|
||||
else:
|
||||
e = self.document.body.getElementById('helpsystem')
|
||||
if not e: return
|
||||
self.document.body.delete_child(e)
|
||||
def key(e):
|
||||
nonlocal keymaps, self
|
||||
if e.key in keymaps['all help']:
|
||||
self.helpsystem_open('table_of_contents.md')
|
||||
elif e.key in keymaps['general help']:
|
||||
self.helpsystem_open('general.md')
|
||||
elif e.key in keymaps['tool help']:
|
||||
if hasattr(self, 'rftool'):
|
||||
self.helpsystem_open(self.rftool.help)
|
||||
elif e.key == 'ESC':
|
||||
close()
|
||||
ui_help = UI_Element.fromHTMLFile(abspath('../html/help_dialog.html'))[0]
|
||||
ui_markdown = ui_help.getElementById('helpsystem-mdown')
|
||||
self.document.body.append_child(ui_help)
|
||||
self.document.body.dirty()
|
||||
|
||||
ui_markdown.set_markdown(
|
||||
mdown_path=mdown_path,
|
||||
preprocess_fns=[
|
||||
self.substitute_keymaps,
|
||||
self.substitute_options,
|
||||
self.substitute_python
|
||||
],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import bpy
|
||||
import json
|
||||
from queue import Queue
|
||||
|
||||
|
||||
from ...config.options import options
|
||||
|
||||
class RetopoFlow_Instrumentation:
|
||||
instrument_queue = Queue()
|
||||
instrument_thread = None
|
||||
|
||||
def instrument_write(self, action):
|
||||
if not options['instrument']: return
|
||||
|
||||
tb_name = options.get_path('instrument_filename')
|
||||
if tb_name not in bpy.data.texts: bpy.data.texts.new(tb_name)
|
||||
tb = bpy.data.texts[tb_name]
|
||||
|
||||
target_json = self.rftarget.to_json()
|
||||
data = {'action': action, 'target': target_json}
|
||||
data_str = json.dumps(data, separators=[',',':'], indent=0)
|
||||
self.instrument_queue.put(data_str)
|
||||
|
||||
# write data to end of textblock asynchronously
|
||||
# TODO: try writing to file (text/binary), because writing to textblock is _very_ slow! :(
|
||||
def write_out():
|
||||
while True:
|
||||
if self.instrument_queue.empty():
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
data_str = self.instrument_queue.get()
|
||||
data_str = data_str.splitlines()
|
||||
tb.write('') # position cursor to end
|
||||
for line in data_str:
|
||||
tb.write(line)
|
||||
tb.write('\n')
|
||||
if not self.instrument_thread:
|
||||
# executor only needed to start the following instrument_thread
|
||||
executor = ThreadPoolExecutor()
|
||||
self.instrument_thread = executor.submit(write_out)
|
||||
@@ -0,0 +1,420 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import os
|
||||
import re
|
||||
import bpy
|
||||
|
||||
from ..updater import updater
|
||||
|
||||
from ...addon_common.common.blender import get_path_from_addon_root
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.utils import delay_exec, abspath
|
||||
from ...addon_common.common.ui_styling import load_defaultstylings
|
||||
from ...addon_common.common.ui_core import UI_Element
|
||||
from ...addon_common.common.human_readable import convert_actions_to_human_readable
|
||||
|
||||
from ...config.options import options
|
||||
from ...config.keymaps import get_keymaps, reset_all_keymaps, save_custom_keymaps, reset_keymap, default_rf_keymaps
|
||||
|
||||
class RetopoFlow_KeymapSystem:
|
||||
@staticmethod
|
||||
def reload_stylings():
|
||||
load_defaultstylings()
|
||||
path = get_path_from_addon_root('config', 'ui.css')
|
||||
try:
|
||||
Globals.ui_draw.load_stylesheet(path)
|
||||
except AssertionError as e:
|
||||
# TODO: show proper dialog to user here!!
|
||||
print('could not load stylesheet "%s"' % path)
|
||||
print(e)
|
||||
Globals.ui_document.body.dirty(cause='Reloaded stylings', children=True)
|
||||
Globals.ui_document.body.dirty_styling()
|
||||
Globals.ui_document.body.dirty_flow()
|
||||
|
||||
def substitute_keymaps(self, mdown, wrap='`', pre='', post='', separator=', ', onlyfirst=None):
|
||||
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
|
||||
else: wrap_pre, wrap_post = wrap
|
||||
while True:
|
||||
m = re.search(r'{{(?P<action>[^}]+)}}', mdown)
|
||||
if not m: break
|
||||
action = { s.strip() for s in m.group('action').split(',') }
|
||||
sub = f'{pre}{wrap_pre}' + self.actions.to_human_readable(action, sep=f'{wrap_post}{separator}{wrap_pre}', onlyfirst=onlyfirst) + f'{wrap_post}{post}'
|
||||
mdown = mdown[:m.start()] + sub + mdown[m.end():]
|
||||
return mdown
|
||||
|
||||
def substitute_options(self, mdown, wrap='', pre='', post='', separator=', ', onlyfirst=None):
|
||||
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
|
||||
else: wrap_pre, wrap_post = wrap
|
||||
while True:
|
||||
m = re.search(r'{\[(?P<option>[^\]]+)\]}', mdown)
|
||||
if not m: break
|
||||
opts = { s.strip() for s in m.group('option').split(',') }
|
||||
sub = f'{pre}{wrap_pre}' + separator.join(str(options[opt]) for opt in opts) + f'{wrap_post}{post}'
|
||||
mdown = mdown[:m.start()] + sub + mdown[m.end():]
|
||||
return mdown
|
||||
|
||||
def substitute_python(self, mdown, wrap='', pre='', post=''):
|
||||
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
|
||||
else: wrap_pre, wrap_post = wrap
|
||||
while True:
|
||||
m = re.search(r'{`(?P<python>[^`]+)`}', mdown)
|
||||
if not m: break
|
||||
pyret = eval(m.group('python'), globals(), locals())
|
||||
sub = f'{pre}{wrap_pre}{pyret}{wrap_post}{post}'
|
||||
mdown = mdown[:m.start()] + sub + mdown[m.end():]
|
||||
return mdown
|
||||
|
||||
def keymap_config_open(self): #, mdown_path, done_on_esc=False, closeable=True, *args, **kwargs):
|
||||
newversion = ''
|
||||
keymaps = get_keymaps(force_reload=True)
|
||||
humanread = self.actions.to_human_readable
|
||||
tokmi = self.actions.from_human_readable
|
||||
|
||||
def ok():
|
||||
save_custom_keymaps()
|
||||
self.done()
|
||||
def cancel():
|
||||
get_keymaps(force_reload=True)
|
||||
self.done()
|
||||
def reset_all():
|
||||
reset_all_keymaps()
|
||||
rebuild()
|
||||
self.alert_user(
|
||||
message='Keymaps reset, but changes will not be saved until OK is clicked',
|
||||
title='Keymaps Config System',
|
||||
level='warning',
|
||||
)
|
||||
|
||||
def key(e):
|
||||
nonlocal keymaps, self
|
||||
pass
|
||||
# if e.key == 'ESC': close()
|
||||
|
||||
def action_to_label(action):
|
||||
for category,actions in keymap_details:
|
||||
for a,al in actions:
|
||||
if a == action: return al
|
||||
return action
|
||||
def action_to_id(a):
|
||||
aid = a.replace(' ', '_')
|
||||
return aid
|
||||
|
||||
def set_edit_key_span(hk, is_key=True):
|
||||
if not is_key:
|
||||
clear_edit_key_span()
|
||||
return
|
||||
span = self.document.body.getElementById('edit-key-span')
|
||||
span.innerText = f'Key: {hk}'
|
||||
# span.style = ''
|
||||
def capture_edit_key_span():
|
||||
span = self.document.body.getElementById('edit-key-span')
|
||||
span.innerText = '(capturing... press key to capture)'
|
||||
# span.style = 'color: rgba(255, 255, 255, 0.5)'
|
||||
def clear_edit_key_span():
|
||||
span = self.document.body.getElementById('edit-key-span')
|
||||
span.innerText = '(click to start capture)'
|
||||
# span.style = 'color: rgba(255, 255, 255, 0.5)'
|
||||
|
||||
edit_data = {}
|
||||
def edit_capture():
|
||||
ui_button = self.document.body.getElementById('edit-key-span')
|
||||
ui_button.can_focus = True
|
||||
self.document.focus(ui_button, full=True)
|
||||
capture_edit_key_span()
|
||||
def edit_capture_mouse(action):
|
||||
clear_edit_key_span()
|
||||
edit_data['key'] = action
|
||||
def edit_capture_key(event):
|
||||
if event.key is None or event.key == 'NONE': return
|
||||
ui_button = self.document.body.getElementById('edit-key-span')
|
||||
if self.document.activeElement != ui_button: return
|
||||
has_ctrl = 'CTRL+' in event.key
|
||||
has_shift = 'SHIFT+' in event.key
|
||||
has_alt = 'ALT+' in event.key
|
||||
has_oskey = 'OSKEY+' in event.key
|
||||
key = event.key.replace('CTRL+','').replace('SHIFT+','').replace('ALT+','').replace('OSKEY+','')
|
||||
set_edit_key_span(humanread([key], visible=True))
|
||||
edit_data['key'] = key
|
||||
editor = self.document.body.getElementById('keymapconfig')
|
||||
editor.getElementById('edit-ctrl').checked = has_ctrl
|
||||
editor.getElementById('edit-shift').checked = has_shift
|
||||
editor.getElementById('edit-alt').checked = has_alt
|
||||
editor.getElementById('edit-oskey').checked = has_oskey
|
||||
self.document.blur()
|
||||
def edit_ok():
|
||||
nonlocal edit_data, keymaps, tokmi
|
||||
if edit_data['key'] == '':
|
||||
self.alert_user(
|
||||
message='Must select a key or mouse interaction first',
|
||||
title='Keymaps Config System',
|
||||
level='warning',
|
||||
)
|
||||
return
|
||||
editor = self.document.body.getElementById('keymapconfig')
|
||||
editor.style = "display: none"
|
||||
self.document.body.getElementById('keymapsystem-cover').style = "display: none"
|
||||
nk = ''
|
||||
nk += 'CTRL+' if editor.getElementById('edit-ctrl').checked else ''
|
||||
nk += 'SHIFT+' if editor.getElementById('edit-shift').checked else ''
|
||||
nk += 'ALT+' if editor.getElementById('edit-alt').checked else ''
|
||||
nk += 'OSKEY+' if editor.getElementById('edit-oskey').checked else ''
|
||||
nk += tokmi(edit_data['key'])[0]
|
||||
nk += '+CLICK' if editor.getElementById('edit-click').checked else ''
|
||||
nk += '+DOUBLE' if editor.getElementById('edit-double').checked else ''
|
||||
nk += '+DRAG' if editor.getElementById('edit-drag').checked else ''
|
||||
a = edit_data['action']
|
||||
al = edit_data['action label']
|
||||
# do not change ordering of keymaps, just update
|
||||
idx = keymaps[a].index(edit_data['keymap'])
|
||||
keymaps[a][idx] = nk
|
||||
rebuild_action(a, al)
|
||||
def edit_cancel():
|
||||
self.document.body.getElementById('keymapconfig').style = "display: none"
|
||||
self.document.body.getElementById('keymapsystem-cover').style = "display: none"
|
||||
if edit_data['keymap'] == '':
|
||||
keymaps[edit_data['action']].remove('')
|
||||
def edit_delete():
|
||||
self.document.body.getElementById('keymapconfig').style = "display: none"
|
||||
self.document.body.getElementById('keymapsystem-cover').style = "display: none"
|
||||
delete_keymap(edit_data['action'], edit_data['keymap'])
|
||||
def edit_start(a, al, k):
|
||||
nonlocal edit_data, keymaps
|
||||
aid = action_to_id(a)
|
||||
ok = str(k)
|
||||
hkctrl, k = 'CTRL+' in k, k.replace('CTRL+', '')
|
||||
hkshift, k = 'SHIFT+' in k, k.replace('SHIFT+', '')
|
||||
hkalt, k = 'ALT+' in k, k.replace('ALT+', '')
|
||||
hkoskey, k = 'OSKEY+' in k, k.replace('OSKEY+', '')
|
||||
hkclick, k = '+CLICK' in k, k.replace('+CLICK', '')
|
||||
hkdouble, k = '+DOUBLE' in k, k.replace('+DOUBLE', '')
|
||||
hkdrag, k = '+DRAG' in k, k.replace('+DRAG', '')
|
||||
hk = humanread(k, visible=True)
|
||||
is_key = hk not in {'LMB', 'MMB', 'RMB', 'WheelUp', 'WheelDown', ''}
|
||||
if not is_key: hm, hk = hk, ''
|
||||
else: hm = ''
|
||||
edit_data['action'] = a
|
||||
edit_data['action label'] = al
|
||||
edit_data['keymap'] = ok
|
||||
edit_data['key'] = k
|
||||
self.document.body.getElementById('keymapsystem-cover').style = ""
|
||||
editor = self.document.body.getElementById('keymapconfig')
|
||||
editor.style = ''
|
||||
editor.getElementById('edit-action').innerText = action_to_label(a)
|
||||
set_edit_key_span(hk, is_key)
|
||||
editor.getElementById('edit-key').checked = is_key
|
||||
editor.getElementById('edit-lmb').checked = (hm == 'LMB')
|
||||
editor.getElementById('edit-mmb').checked = (hm == 'MMB')
|
||||
editor.getElementById('edit-rmb').checked = (hm == 'RMB')
|
||||
editor.getElementById('edit-wu').checked = (hm == 'WheelUp')
|
||||
editor.getElementById('edit-wd').checked = (hm == 'WheelDown')
|
||||
editor.getElementById('edit-ctrl').checked = hkctrl
|
||||
editor.getElementById('edit-shift').checked = hkshift
|
||||
editor.getElementById('edit-alt').checked = hkalt
|
||||
editor.getElementById('edit-oskey').checked = hkoskey
|
||||
editor.getElementById('edit-press').checked = not (hkclick or hkdouble or hkdrag)
|
||||
editor.getElementById('edit-click').checked = hkclick
|
||||
editor.getElementById('edit-double').checked = hkdouble
|
||||
editor.getElementById('edit-drag').checked = hkdrag
|
||||
|
||||
def add_keymap(a, al):
|
||||
nonlocal keymaps
|
||||
keymaps[a].append('')
|
||||
edit_start(a, al, '')
|
||||
def delete_keymap(a, al, k):
|
||||
keymaps[a].remove(k)
|
||||
rebuild_action(a, al)
|
||||
|
||||
def keymap_html(a, al):
|
||||
nonlocal edit_start, delete_keymap, rebuild_action, add_keymap
|
||||
aid = action_to_id(a)
|
||||
html = ''
|
||||
for k in keymaps[a]:
|
||||
html += f'''<button id="keymap-{aid}-key" class="key" on_mouseclick="edit_start('{a}', '{al}', '{k}')" title="Click to edit this keymap for {al}">{humanread(k, visible=True)}</button>'''
|
||||
html += f'''<button id="keymap-{aid}-del" class="delkey" on_mouseclick="delete_keymap('{a}', '{al}', '{k}')" title="Click to delete this keymap for {al}">✕</button>'''
|
||||
html += f'''<button class="half-size" on_mouseclick="add_keymap('{a}', '{al}')" title="Click to add a new keymap for {al}">+ Add New Keymap</button>'''
|
||||
html += f'''<button class="half-size" on_mouseclick="reset_keymap('{a}'); rebuild_action('{a}', '{al}')" title="Click to reset the keymaps for {al}">Reset Keymap</button>'''
|
||||
return html
|
||||
def rebuild_action(a, al):
|
||||
# vvv this must be here so fromHTML() can see these fns!
|
||||
nonlocal edit_start, delete_keymap, rebuild_action, add_keymap
|
||||
# ^^^ this must be here so fromHTML() can see these fns!
|
||||
|
||||
aid = action_to_id(a)
|
||||
ui_td = self.document.body.getElementById(f'keymap-{aid}')
|
||||
ui_td.clear_children()
|
||||
ui_td.append_children(UI_Element.fromHTML(keymap_html(a, al)))
|
||||
def rebuild():
|
||||
# vvv this must be here so fromHTML() can see these fns!
|
||||
nonlocal edit_start, delete_keymap, rebuild_action, add_keymap
|
||||
# ^^^ this must be here so fromHTML() can see these fns!
|
||||
|
||||
ui_keymaps = self.document.body.getElementById('keymaps')
|
||||
html = ''
|
||||
for category,actions in keymap_details:
|
||||
html += f'<details>'
|
||||
html += f'<summary>{category}</summary>'
|
||||
html += f'<table>'
|
||||
for a,al in actions:
|
||||
aid = action_to_id(a)
|
||||
html += f'<tr>'
|
||||
html += f'<td class="action">{al}:</td>'
|
||||
html += f'<td id="keymap-{aid}" class="keymap">{keymap_html(a, al)}</td>'
|
||||
html += f'</tr>'
|
||||
html += f'</table>'
|
||||
html += f'</details>'
|
||||
ui_keymaps.clear_children()
|
||||
ui_keymaps.append_children(UI_Element.fromHTML(html))
|
||||
|
||||
|
||||
ui_keymaps = UI_Element.fromHTMLFile(abspath('../html/keymaps_dialog.html'))
|
||||
self.document.body.append_children(ui_keymaps)
|
||||
self.document.body.getElementById('keymapconfig').style = 'display: none'
|
||||
self.document.body.getElementById('keymapsystem-cover').style = "display: none"
|
||||
|
||||
self.document.body.getElementById('edit-ctrl').innerText = convert_actions_to_human_readable('CTRL')
|
||||
self.document.body.getElementById('edit-shift').innerText = convert_actions_to_human_readable('SHIFT')
|
||||
self.document.body.getElementById('edit-oskey').innerText = convert_actions_to_human_readable('OSKEY')
|
||||
self.document.body.getElementById('edit-alt').innerText = convert_actions_to_human_readable('ALT')
|
||||
|
||||
rebuild()
|
||||
self.document.body.dirty()
|
||||
|
||||
|
||||
keymap_details = [
|
||||
('General', [
|
||||
('confirm', 'Confirm'),
|
||||
('confirm drag', 'Confirm with Drag (sometimes this is needed for certain actions)'),
|
||||
('confirm quick', 'Confirm with quick switch tool'),
|
||||
('cancel', 'Cancel'),
|
||||
('done', 'Quit RetopoFlow'),
|
||||
('done alt0', 'Quit RetopoFlow (alternative)'),
|
||||
('toggle ui', 'Toggle UI visibility'),
|
||||
('blender passthrough', 'Blender passthrough'),
|
||||
]),
|
||||
('Insert, Move, Rotate, Scale', [
|
||||
('insert', 'Insert new geometry'),
|
||||
('quick insert', 'Quick insert (Knife, Loops)'),
|
||||
('increase count', 'Increase Count'),
|
||||
('decrease count', 'Decrease Count'),
|
||||
('action', 'Action'),
|
||||
('action alt0', 'Action (alt0)'),
|
||||
('action alt1', 'Action (alt1)'),
|
||||
('grab', 'Grab / move'),
|
||||
('rotate', 'Rotate'),
|
||||
('scale', 'Scale'),
|
||||
('delete', 'Show delete menu'),
|
||||
('delete pie menu', 'Show delete/dissolve/merge pie menu'),
|
||||
# ('merge', 'Show merge menu'),
|
||||
('smooth edge flow', 'Smooth edge flow of selected geometry'),
|
||||
('rotate plane', 'Contours: rotate plane'),
|
||||
('rotate screen', 'Contours: rotate screen'),
|
||||
('slide', 'Loops: slide loop'),
|
||||
('fill', 'Patches: fill'),
|
||||
('knife reset', 'Knife: reset'),
|
||||
('rip', 'PolyPen: rip'),
|
||||
('rip fill', 'PolyPen: rip fill'),
|
||||
]),
|
||||
('Selection', [
|
||||
('select all', 'Select all'),
|
||||
('select invert', 'Select invert'),
|
||||
('select linked', 'Select linked'),
|
||||
('select linked mouse', 'Select linked under mouse'),
|
||||
('deselect linked mouse', 'Deselect linked under mouse'),
|
||||
('deselect all', 'Deselect all'),
|
||||
('select single', 'Select single item (default depends on Blender selection setting)'),
|
||||
('select single add', 'Add single item to selection (default depends on Blender selection setting)'),
|
||||
('select smart', 'Smart selection (default depends on Blender selection setting)'),
|
||||
('select smart add', 'Smart add to selection (default depends on Blender selection setting)'),
|
||||
('select paint', 'Selection painting (default depends on Blender selection setting)'),
|
||||
('select paint add', 'Paint to add to selection (default depends on Blender selection setting)'),
|
||||
('select path add', 'Select along shortest path (default depends on Blender selection setting)'),
|
||||
]),
|
||||
('Geometry Attributes', [
|
||||
('hide selected', 'Hide selected geometry'),
|
||||
('hide unselected', 'Hide unselected geometry'),
|
||||
('reveal hidden', 'Reveal hidden geometry'),
|
||||
('pin', 'Pin selected geometry'),
|
||||
('unpin', 'Unpin selected geometry'),
|
||||
('unpin all', 'Unpin all geometry'),
|
||||
('mark seam', 'Mark selected edges as seam'),
|
||||
('clear seam', 'Unmark selected edges as seam'),
|
||||
]),
|
||||
('Switching Between Tools', [
|
||||
('contours tool', 'Switch to Contours'),
|
||||
('polystrips tool', 'Switch to PolyStrips'),
|
||||
('strokes tool', 'Switch to Strokes'),
|
||||
('patches tool', 'Switch to Patches'),
|
||||
('polypen tool', 'Switch to PolyPen'),
|
||||
('knife tool', 'Switch to Knife'),
|
||||
('knife quick', 'Quick switch to Knife'),
|
||||
('loops tool', 'Switch to Loops'),
|
||||
('loops quick', 'Quick switch to Loops'),
|
||||
('tweak tool', 'Switch to Tweak'),
|
||||
('tweak quick', 'Quick switch to Tweak'),
|
||||
('relax tool', 'Switch to Relax'),
|
||||
('relax quick', 'Quick switch to Relax'),
|
||||
('select tool', 'Switch to Select'),
|
||||
('select quick', 'Quick switch to Select'),
|
||||
]),
|
||||
('Brush Actions', [
|
||||
('brush', 'Brush'),
|
||||
('brush alt', 'Brush (alt)'),
|
||||
('brush radius', 'Change brush radius'),
|
||||
('brush radius increase', 'Increase brush radius'),
|
||||
('brush radius decrease', 'Decrease brush radius'),
|
||||
('brush falloff', 'Change brush falloff'),
|
||||
('brush strength', 'Change brush strength'),
|
||||
]),
|
||||
('Pie Menus', [
|
||||
('pie menu', 'Show pie menu'),
|
||||
('pie menu alt0', 'Show tool/alt pie menu'),
|
||||
('pie menu confirm', 'Confirm pie menu selection'),
|
||||
]),
|
||||
('Help', [
|
||||
('all help', 'Show all help'),
|
||||
('general help', 'Show general help'),
|
||||
('tool help', 'Show help for selected tool'),
|
||||
]),
|
||||
]
|
||||
|
||||
ignored_keys = {
|
||||
'autosave',
|
||||
'grease clear', 'grease pencil tool',
|
||||
'stretch tool',
|
||||
'toggle full area',
|
||||
'reload css',
|
||||
'select box', 'select box del', 'select box add',
|
||||
'merge',
|
||||
}
|
||||
|
||||
# check that all keymaps are able to be edited
|
||||
def check_keymap_editor():
|
||||
flattened_details = { key for (_, keyset) in keymap_details for (key, _) in keyset }
|
||||
default_keys = set(default_rf_keymaps.keys()) - ignored_keys
|
||||
missing_keys = default_keys - flattened_details
|
||||
extra_keys = flattened_details - default_keys
|
||||
if not missing_keys and not extra_keys: return
|
||||
print(f'Error detected in keymap editor')
|
||||
if missing_keys: print(f'Missing Keys: {sorted(missing_keys)}\nEither add to keymap_details or ignored_keys in rf_keymapsystem.py')
|
||||
if extra_keys: print(f'Extra Keys: {sorted(extra_keys)}\nRemove these from keymap_details')
|
||||
assert False
|
||||
check_keymap_editor()
|
||||
@@ -0,0 +1,220 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import os
|
||||
import bpy
|
||||
import json
|
||||
import time
|
||||
import functools
|
||||
from datetime import datetime
|
||||
from itertools import chain
|
||||
from mathutils import Matrix, Vector
|
||||
from bpy_extras.object_utils import object_data_add
|
||||
|
||||
from .rf_blender_objects import RetopoFlow_Blender_Objects
|
||||
from ...config.options import sessionoptions, options
|
||||
|
||||
from ...addon_common.cookiecutter.cookiecutter_blender import CookieCutter_Blender
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.decorators import blender_version_wrapper
|
||||
from ...addon_common.common.blender import set_object_selection, set_active_object, get_active_object, get_view3d_space
|
||||
from ...addon_common.common.blender import toggle_screen_header, toggle_screen_toolbar, toggle_screen_properties, toggle_screen_lastop
|
||||
from ...addon_common.common.maths import BBox, XForm, Point
|
||||
from ...addon_common.common.debug import dprint
|
||||
|
||||
class RetopoFlow_Normalize:
|
||||
'''
|
||||
allows RetopoFlow to work with normalized lengths
|
||||
'''
|
||||
|
||||
def update_view_sessionoptions(self, context):
|
||||
space = get_view3d_space(context)
|
||||
r3d = space.region_3d
|
||||
normalize_opts = sessionoptions['normalize']
|
||||
fac = normalize_opts['view scaling factor']
|
||||
view_opts = normalize_opts['view']
|
||||
view_opts['distance'] = r3d.view_distance / fac
|
||||
view_opts['location'] = r3d.view_location / fac
|
||||
|
||||
@CookieCutter_Blender.blender_change_callback
|
||||
def blenderui_change_callback(self, storage):
|
||||
sessionoptions['blender'] = dict(storage)
|
||||
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _normalize_set(
|
||||
*,
|
||||
factor=None, # ignored if None or <= 0
|
||||
context=None, space=None,
|
||||
restore_all=False,
|
||||
view='SCALE', # {'SCALE', 'OVERRIDE', 'RESTORE', 'IGNORE'}
|
||||
view_distance=None, view_location=None, # ignored if view != 'OVERRIDE'
|
||||
clip='SCALE', # {'SCALE', 'OVERRIDE', 'RESTORE', 'IGNORE'}
|
||||
clip_start=None, clip_end=None, # ignored if clip != 'OVERRIDE'
|
||||
mesh='SCALE', # {'SCALE', 'RESTORE', 'IGNORE'}
|
||||
):
|
||||
assert context or space, f'Must specify either context or space'
|
||||
if not space: space = get_view3d_space(context)
|
||||
assert space.type == 'VIEW_3D', f"space.type must be 'VIEW_3D', not '{space.type}'"
|
||||
r3d = space.region_3d
|
||||
|
||||
normalize_opts = sessionoptions['normalize']
|
||||
|
||||
if restore_all:
|
||||
view = clip = mesh = 'RESTORE'
|
||||
factor = 1.0
|
||||
|
||||
rf_target = RetopoFlow_Blender_Objects.get_target()
|
||||
|
||||
sessionoptions['retopoflow']['target'] = rf_target.name
|
||||
print(f'RetopoFlow: scaling to {factor=}, {view=}, {clip=}, {mesh=}')
|
||||
|
||||
# scale view
|
||||
orig_view = normalize_opts['view']
|
||||
if view in {'SCALE', 'RESTORE'}:
|
||||
fac = factor if view == 'SCALE' else 1.0
|
||||
if fac and fac > 0.0:
|
||||
r3d.view_distance = orig_view['distance'] * fac
|
||||
r3d.view_location = Vector(orig_view['location']) * fac
|
||||
normalize_opts['view scaling factor'] = fac
|
||||
elif view == 'OVERRIDE':
|
||||
if view_distance is not None: r3d.view_distance = view_distance
|
||||
if view_location is not None: r3d.view_location = view_location
|
||||
elif view == 'IGNORE':
|
||||
pass
|
||||
else:
|
||||
assert False, f'unexpected view ({view})'
|
||||
|
||||
# scale clip start and end
|
||||
orig_clip = normalize_opts['clip distances']
|
||||
if clip in {'SCALE', 'RESTORE'}:
|
||||
fac = (factor if clip == 'SCALE' else 1.0) or 0.0
|
||||
if fac > 0.0:
|
||||
space.clip_start = orig_clip['start'] * fac
|
||||
space.clip_end = orig_clip['end'] * fac
|
||||
elif clip == 'OVERRIDE':
|
||||
if clip_start is not None: space.clip_start = clip_start
|
||||
if clip_end is not None: space.clip_end = clip_end
|
||||
elif clip == 'IGNORE':
|
||||
pass
|
||||
else:
|
||||
assert False, f'unexpected clip ({clip})'
|
||||
|
||||
# scale meshes
|
||||
if mesh in {'SCALE', 'RESTORE'}:
|
||||
fac = (factor if mesh == 'SCALE' else 1.0) or 0.0
|
||||
if fac > 0.0:
|
||||
prev_factor = normalize_opts['mesh scaling factor']
|
||||
M = (Matrix.Identity(3) * (fac / prev_factor)).to_4x4()
|
||||
sources = RetopoFlow_Blender_Objects.get_sources()
|
||||
targets = [rf_target]
|
||||
for obj in chain(sources, targets):
|
||||
if not obj: continue
|
||||
armature = next((mod.object for mod in obj.modifiers if mod.type == 'ARMATURE'), None)
|
||||
if not armature:
|
||||
obj.matrix_world = M @ obj.matrix_world
|
||||
else:
|
||||
print(f' {obj.name} has an armature modifier with object {armature.name}')
|
||||
# armature.matrix_world = M @ armature.matrix_world
|
||||
obj.matrix_world = M @ obj.matrix_world
|
||||
normalize_opts['mesh scaling factor'] = fac
|
||||
elif mesh == 'IGNORE':
|
||||
pass
|
||||
else:
|
||||
assert False, f'unexpected mesh ({mesh})'
|
||||
|
||||
@property
|
||||
def unit_scaling_factor(self):
|
||||
normalize_opts = sessionoptions['normalize']
|
||||
return normalize_opts['unit scaling factor']
|
||||
|
||||
@staticmethod
|
||||
def end_normalize(context):
|
||||
print('RetopoFlow: unscaling from unit box')
|
||||
RetopoFlow_Normalize._normalize_set(context=context, restore_all=True)
|
||||
|
||||
def start_normalize(self):
|
||||
print('RetopoFlow: scaling to unit box')
|
||||
self._normalize_set(
|
||||
factor=self.unit_scaling_factor,
|
||||
space=self.context.space_data,
|
||||
clip='OVERRIDE' if options['clip override'] else 'SCALE',
|
||||
clip_start=options['clip start override'],
|
||||
clip_end=options['clip end override'],
|
||||
)
|
||||
self.scene_scale_set(1.0)
|
||||
|
||||
def init_normalize(self):
|
||||
'''
|
||||
initializes normalize functions
|
||||
call only once!
|
||||
'''
|
||||
|
||||
self.blender_change_init(sessionoptions['blender'])
|
||||
normalize_opts = sessionoptions['normalize']
|
||||
|
||||
space = self.context.space_data
|
||||
assert space.type == 'VIEW_3D', f"space.type must be 'VIEW_3D', not '{space.type}'"
|
||||
r3d = space.region_3d
|
||||
|
||||
# store original clip distances
|
||||
print(f'RetopoFlow: storing clip distances: {space.clip_start} {space.clip_end}')
|
||||
normalize_opts['clip distances'] = {
|
||||
'start': space.clip_start,
|
||||
'end': space.clip_end,
|
||||
}
|
||||
|
||||
# store original view
|
||||
print(f'RetopoFlow: storing view: {r3d.view_location} {r3d.view_distance}')
|
||||
normalize_opts['view'] = {
|
||||
'distance': r3d.view_distance,
|
||||
'location': r3d.view_location,
|
||||
}
|
||||
|
||||
print('RetopoFlow: computing unit scaling factor')
|
||||
normalize_opts['unit scaling factor'] = self._compute_unit_scaling_factor()
|
||||
print(f' Unit scaling factor: {self.unit_scaling_factor}')
|
||||
|
||||
self.start_normalize()
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _compute_unit_scaling_factor():
|
||||
def get_source_bbox(s):
|
||||
verts = [s.matrix_world @ Vector((v[0], v[1], v[2], 1)) for v in s.bound_box]
|
||||
verts = [(v[0] / v[3], v[1] / v[3], v[2] / v[3]) for v in verts]
|
||||
return BBox(from_coords=verts)
|
||||
sources = RetopoFlow_Blender_Objects.get_sources()
|
||||
if not sources: return 1.0
|
||||
bbox = BBox.merge( get_source_bbox(s) for s in sources )
|
||||
max_length = bbox.get_max_dimension()
|
||||
scene_scale = 1.0 # bpy.context.scene.unit_settings.scale_length
|
||||
magic_scale = 10.0 # to make the unit box manageable
|
||||
return (scene_scale * magic_scale) / max_length
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import math
|
||||
import time
|
||||
import random
|
||||
from itertools import chain
|
||||
from collections import deque
|
||||
|
||||
from ..rfmesh.rfmesh_wrapper import RFVert, RFEdge, RFFace
|
||||
|
||||
from ...addon_common.cookiecutter.cookiecutter import CookieCutter
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.decorators import timed_call
|
||||
from ...addon_common.common.drawing import Cursors
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.maths import Vec2D, Point2D, RelPoint2D, Direction2D
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.ui_core import UI_Element
|
||||
from ...addon_common.common.utils import normalize_triplequote, Dict
|
||||
from ...config.options import options, retopoflow_files
|
||||
from ...addon_common.common.timerhandler import StopwatchHandler
|
||||
|
||||
class RetopoFlow_PieMenu:
|
||||
def which_pie_menu_section(self):
|
||||
delta = self.actions.mouse - self.pie_menu_center
|
||||
if delta.length < self.pie_menu_center_size / 2: return None
|
||||
count = len(self.pie_menu_options)
|
||||
clock_deg = (math.atan2(-delta.y, delta.x) * 180 / math.pi - self.pie_menu_rotation) % 360
|
||||
section = math.floor((clock_deg + 360 / count / 2) % 360 / (360 / count))
|
||||
return section
|
||||
|
||||
@staticmethod
|
||||
def _estimate_text_height(text, wrap_width):
|
||||
font_w, font_h = 6, 16 # very rough approximation!
|
||||
line_count = 0
|
||||
for line in text.splitlines():
|
||||
text_w = max(1, len(line)) * font_w # approx width of text w/o wrapping
|
||||
line_count += math.ceil(text_w / wrap_width) # approx num of lines w/ wrapping
|
||||
text_h = max(1, line_count) * font_h # approx height of text w/ wrapping
|
||||
return text_h
|
||||
|
||||
@FSM.on_state('pie menu', 'enter')
|
||||
def pie_menu_enter(self):
|
||||
scale = self.drawing.scale
|
||||
doc_h = self.document.body.height_pixels
|
||||
menu = Dict(
|
||||
size = 512, # size of full menu
|
||||
radius = 204, # size of option center ring (40% of menu)
|
||||
option = 72, # size of option
|
||||
inner = 100, # size of inner circle
|
||||
)
|
||||
center = self.actions.mouse - Vec2D((scale(menu.size) / 2, -scale(menu.size) / 2)) - Vec2D((0, doc_h))
|
||||
ui_pie_menu_contents = self.ui_pie_menu.getElementById('pie-menu-contents')
|
||||
ui_pie_menu_contents.clear_children()
|
||||
ui_pie_menu_contents.style = ';'.join([
|
||||
f'left:{center.x}px',
|
||||
f'top:{center.y}px',
|
||||
f'width:{menu.size}px',
|
||||
f'height:{menu.size}px',
|
||||
f'border-radius:{menu.size // 2}px',
|
||||
f'padding:{menu.size // 2}px',
|
||||
])
|
||||
count = len(self.pie_menu_options)
|
||||
self.ui_pie_sections = []
|
||||
for i_option, option in enumerate(self.pie_menu_options):
|
||||
if not option:
|
||||
self.ui_pie_sections.append(None)
|
||||
continue
|
||||
if type(option) is str: option = (option,)
|
||||
if type(option) is tuple: option = { k:v for k,v in zip(['text', 'value', 'image'], option) }
|
||||
option.setdefault('value', option['text'])
|
||||
option.setdefault('image', '')
|
||||
self.pie_menu_options[i_option] = option['value']
|
||||
r = ((i_option / count) * 360 + self.pie_menu_rotation) * (math.pi / 180)
|
||||
left, top = scale(menu.radius) * math.cos(r) - (scale(menu.option)/2), -(scale(menu.radius) * math.sin(r) - (scale(menu.option)/2))
|
||||
label = UI_Element.DIV(classes='pie-menu-option-text', innerText=option['text'])
|
||||
image = None
|
||||
highlight_class = 'highlighted' if option['value'] == self.pie_menu_highlighted else ''
|
||||
if option['image']:
|
||||
image = UI_Element.IMG(classes='pie-menu-option-image', src=option['image'], style=f'width:{menu.option}px')
|
||||
else:
|
||||
# TODO: actually handle vertical-align: middle!
|
||||
text_h = self._estimate_text_height(option['text'], menu.option) # very rough approximation!
|
||||
margin = (menu.option - text_h) // 2 # offset using margin
|
||||
label.style = f'margin-top:{margin}px'
|
||||
ui = UI_Element.DIV(
|
||||
style=';'.join([
|
||||
f'left:{int(left)}px',
|
||||
f'top:{int(top)}px',
|
||||
f'width:{menu.option}px',
|
||||
f'height:{menu.option}px',
|
||||
]),
|
||||
classes=f"pie-menu-option {highlight_class}",
|
||||
children=list(filter(None, [ label, image ])),
|
||||
parent=ui_pie_menu_contents,
|
||||
)
|
||||
self.ui_pie_sections.append(ui)
|
||||
|
||||
UI_Element.DIV(
|
||||
style=';'.join([
|
||||
f'left:{-scale(menu.inner) // 2}px',
|
||||
f'top:{scale(menu.inner) // 2}px',
|
||||
f'width:{menu.inner}px',
|
||||
f'height:{menu.inner}px',
|
||||
f'border-radius:{menu.inner // 2}px',
|
||||
]),
|
||||
classes=f'pie-menu-inner',
|
||||
parent=ui_pie_menu_contents,
|
||||
)
|
||||
|
||||
self.ui_pie_menu.is_visible = True
|
||||
self.pie_menu_center = self.actions.mouse
|
||||
self.pie_menu_center_size = scale(menu.inner)
|
||||
self.pie_menu_mouse = self.actions.mouse
|
||||
self.document.focus(self.ui_pie_menu)
|
||||
self.document.force_clean(self.actions.context)
|
||||
|
||||
@FSM.on_state('pie menu')
|
||||
def pie_menu_main(self):
|
||||
confirm_p = self.actions.pressed('pie menu confirm', ignoremods=True)
|
||||
confirm_r = self.actions.released(self.pie_menu_release, ignoremods=True)
|
||||
if confirm_p or confirm_r:
|
||||
# setting display to none in case callback needs to show some UI
|
||||
self.ui_pie_menu.is_visible = False
|
||||
i_option = self.which_pie_menu_section()
|
||||
option = self.pie_menu_options[i_option] if i_option is not None else None
|
||||
if option is not None or self.pie_menu_always_callback:
|
||||
self.pie_menu_callback(option)
|
||||
return 'main' if confirm_r else 'pie menu wait'
|
||||
if self.actions.pressed('cancel'):
|
||||
return 'pie menu wait'
|
||||
i_section = self.which_pie_menu_section()
|
||||
for i_s,ui in enumerate(self.ui_pie_sections):
|
||||
if not ui: continue
|
||||
if i_s == i_section:
|
||||
ui.add_pseudoclass('hover')
|
||||
else:
|
||||
ui.del_pseudoclass('hover')
|
||||
|
||||
@FSM.on_state('pie menu', 'exit')
|
||||
def pie_menu_exit(self):
|
||||
self.ui_pie_menu.is_visible = False
|
||||
|
||||
@FSM.on_state('pie menu wait')
|
||||
def pie_menu_wait(self):
|
||||
if self.actions.released(self.pie_menu_release, ignoremods=True):
|
||||
return 'main'
|
||||
@@ -0,0 +1,289 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import bpy
|
||||
import time
|
||||
from math import isinf, isnan
|
||||
|
||||
from ...config.options import visualization, options
|
||||
from ...addon_common.common.maths import BBox
|
||||
from ...addon_common.common.profiler import profiler, time_it
|
||||
from ...addon_common.common.debug import dprint
|
||||
from ...addon_common.common.maths import Point, Vec, Direction, Normal, Ray, XForm, Plane
|
||||
from ...addon_common.common.maths import Point2D
|
||||
from ...addon_common.common.maths_accel import Accel2D
|
||||
from ...addon_common.common.timerhandler import CallGovernor
|
||||
|
||||
from ..rfmesh.rfmesh import RFSource
|
||||
from ..rfmesh.rfmesh_render import RFMeshRender
|
||||
|
||||
|
||||
class RetopoFlow_Sources:
|
||||
'''
|
||||
functions to work on all source meshes (RFSource)
|
||||
'''
|
||||
|
||||
# @profiler.function
|
||||
def setup_sources(self):
|
||||
''' find all valid source objects, which are mesh objects that are visible and not active '''
|
||||
print(' rfsources...')
|
||||
self.rfsources = [RFSource.new(src) for src in self.get_sources()]
|
||||
print(' bboxes...')
|
||||
self.sources_bbox = BBox.merge(rfs.get_bbox() for rfs in self.rfsources)
|
||||
# dprint('%d sources found' % len(self.rfsources))
|
||||
pass
|
||||
opts = visualization.get_source_settings()
|
||||
print(' drawing...')
|
||||
self.rfsources_draw = [RFMeshRender.new(rfs, opts) for rfs in self.rfsources]
|
||||
# dprint('%d sources found' % len(self.rfsources))
|
||||
pass
|
||||
print(' done!')
|
||||
self._detected_bad_normals = False
|
||||
self._warned_bad_normals = False
|
||||
|
||||
def done_sources(self):
|
||||
for rfs in self.rfsources:
|
||||
rfs.obj.to_mesh_clear()
|
||||
del self.sources_bbox
|
||||
del self.rfsources_draw
|
||||
del self.rfsources
|
||||
|
||||
# @profiler.function
|
||||
def setup_sources_symmetry(self):
|
||||
xyplane,xzplane,yzplane = self.rftarget.get_xy_plane(),self.rftarget.get_xz_plane(),self.rftarget.get_yz_plane()
|
||||
w2l_point = self.rftarget.w2l_point
|
||||
rfsources_xyplanes = [e for rfs in self.rfsources for e in rfs.plane_intersection(xyplane)]
|
||||
rfsources_xzplanes = [e for rfs in self.rfsources for e in rfs.plane_intersection(xzplane)]
|
||||
rfsources_yzplanes = [e for rfs in self.rfsources for e in rfs.plane_intersection(yzplane)]
|
||||
|
||||
def gen_accel(edges, Point_to_Point2D):
|
||||
nonlocal w2l_point
|
||||
edges = [(w2l_point(v0), w2l_point(v1)) for (v0, v1) in edges]
|
||||
return Accel2D.simple_edges('RFSource edges', edges, Point_to_Point2D)
|
||||
|
||||
self.rftarget.set_symmetry_accel(
|
||||
gen_accel(rfsources_xyplanes, lambda p,_:[Point2D((p.x,p.y))]),
|
||||
gen_accel(rfsources_xzplanes, lambda p,_:[Point2D((p.x,p.z))]),
|
||||
gen_accel(rfsources_yzplanes, lambda p,_:[Point2D((p.y,p.z))]),
|
||||
)
|
||||
|
||||
###################################################
|
||||
# snap settings
|
||||
|
||||
snap_sources = {}
|
||||
|
||||
@staticmethod
|
||||
def get_source_snap(name):
|
||||
return RFContext_Sources.snap_sources.get(name, True)
|
||||
|
||||
@staticmethod
|
||||
def set_source_snap(name, val):
|
||||
RFContext_Sources.snap_sources[name] = val
|
||||
|
||||
def get_rfsource_snap(self, rfsource):
|
||||
n = rfsource.get_obj_name()
|
||||
return self.snap_sources.get(n, True)
|
||||
|
||||
###################################################
|
||||
# ray casting functions
|
||||
|
||||
def raycast_sources_Ray(self, ray:Ray, *, correct_mirror=None, ignore_backface=None):
|
||||
if correct_mirror is None: correct_mirror = options['symmetry mirror input']
|
||||
ignore_backface = self.ray_ignore_backface_sources() if ignore_backface is None else ignore_backface
|
||||
bp,bn,bi,bd,bo = None,None,None,None,None
|
||||
for rfsource in self.rfsources:
|
||||
if not self.get_rfsource_snap(rfsource): continue
|
||||
hp,hn,hi,hd = rfsource.raycast(ray, ignore_backface=ignore_backface)
|
||||
if hp is None: continue # did we miss?
|
||||
if isinf(hd): continue # is distance infinitely far away?
|
||||
if isnan(hd): continue # is distance NaN? (issue #1062)
|
||||
if bp and bd < hd: continue # have we seen a closer hit already?
|
||||
bp,bn,bi,bd,bo = hp,hn,hi,hd,rfsource
|
||||
if correct_mirror and bp and bn: bp, bn = self.mirror_point_normal(bp, bn)
|
||||
return (bp,bn,bi,bd)
|
||||
|
||||
def raycast_sources_Ray_all(self, ray:Ray):
|
||||
return [
|
||||
hit
|
||||
for rfsource in self.rfsources
|
||||
for hit in rfsource.raycast_all(ray)
|
||||
if self.get_rfsource_snap(rfsource)
|
||||
]
|
||||
|
||||
def raycast_sources_Point2D(self, xy:Point2D, *, correct_mirror=None, ignore_backface=None):
|
||||
if xy is None: return None,None,None,None
|
||||
return self.raycast_sources_Ray(self.Point2D_to_Ray(xy, min_dist=self.drawing.space.clip_start), correct_mirror=correct_mirror, ignore_backface=ignore_backface)
|
||||
|
||||
def raycast_sources_Point2D_all(self, xy:Point2D):
|
||||
if xy is None: return None,None,None,None
|
||||
return self.raycast_sources_Ray_all(self.Point2D_to_Ray(xy, min_dist=self.drawing.space.clip_start))
|
||||
|
||||
def raycast_sources_mouse(self, *, correct_mirror=None, ignore_backface=None):
|
||||
return self.raycast_sources_Point2D(self.actions.mouse, correct_mirror=correct_mirror, ignore_backface=ignore_backface)
|
||||
|
||||
def raycast_sources_Point(self, xyz:Point, *, correct_mirror=None, ignore_backface=None):
|
||||
if xyz is None: return None,None,None,None
|
||||
xy = self.Point_to_Point2D(xyz)
|
||||
return self.raycast_sources_Point2D(xy, correct_mirror=correct_mirror, ignore_backface=ignore_backface)
|
||||
|
||||
|
||||
###################################################
|
||||
# nearest surface point (snapping) functions
|
||||
|
||||
def nearest_sources_Point(self, point:Point, max_dist=float('inf')): #sys.float_info.max):
|
||||
bp,bn,bi,bd = None,None,None,None
|
||||
for rfsource in self.rfsources:
|
||||
if not self.get_rfsource_snap(rfsource): continue
|
||||
hp,hn,hi,hd = rfsource.nearest(point, max_dist=max_dist)
|
||||
if bp is None or (hp is not None and hd < bd):
|
||||
bp,bn,bi,bd = hp,hn,hi,hd
|
||||
return (bp,bn,bi,bd)
|
||||
|
||||
|
||||
###################################################
|
||||
# plane intersection
|
||||
|
||||
def plane_intersection_crawl(self, ray:Ray, plane:Plane, walk_to_plane=False):
|
||||
bp,bn,bi,bd,bo = None,None,None,None,None
|
||||
for rfsource in self.rfsources:
|
||||
if not self.get_rfsource_snap(rfsource): continue
|
||||
hp,hn,hi,hd = rfsource.raycast(ray)
|
||||
if bp is None or (hp is not None and hd < bd):
|
||||
bp,bn,bi,bd,bo = hp,hn,hi,hd,rfsource
|
||||
if not bo: return []
|
||||
return bo.plane_intersection_crawl(ray, plane, walk_to_plane=walk_to_plane)
|
||||
|
||||
def plane_intersections_crawl(self, plane:Plane):
|
||||
return [crawl for rfsource in self.rfsources for crawl in rfsource.plane_intersections_crawl(plane) if self.get_rfsource_snap(rfsource)]
|
||||
|
||||
|
||||
###################################################
|
||||
# visibility testing
|
||||
|
||||
def ray_ignore_backface_sources(self):
|
||||
return self.shading_backface_get()
|
||||
|
||||
def _raycast_hit_any(self, ray, ignore_backface):
|
||||
return any(
|
||||
rfsource.raycast_hit(ray, ignore_backface=ignore_backface)
|
||||
for rfsource in self.rfsources if self.get_rfsource_snap(rfsource)
|
||||
)
|
||||
|
||||
def gen_is_visible(self, *, bbox_factor_override=None, dist_offset_override=None, occlusion_test_override=None, backface_test_override=None):
|
||||
backface_test = options['selection backface test'] if backface_test_override is None else backface_test_override
|
||||
occlusion_test = options['selection occlusion test'] if occlusion_test_override is None else occlusion_test_override
|
||||
bbox_factor = options['visible bbox factor'] if bbox_factor_override is None else bbox_factor_override
|
||||
dist_offset = options['visible dist offset'] if dist_offset_override is None else dist_offset_override
|
||||
max_dist_offset = self.sources_bbox.get_min_dimension() * bbox_factor + dist_offset
|
||||
Point_to_Point2D = self.Point_to_Point2D
|
||||
Point_to_Ray = self.Point_to_Ray
|
||||
raycast_hit_any = self._raycast_hit_any
|
||||
ray_ignore_backface_sources = self.ray_ignore_backface_sources()
|
||||
area_x, area_y = self.actions.size.x, self.actions.size.y
|
||||
clip_start = self.drawing.space.clip_start
|
||||
vec_fwd = self.Vec_forward()
|
||||
|
||||
def is_inside_area(point):
|
||||
return (p2D := Point_to_Point2D(point)) and (0 <= p2D.x <= area_x) and (0 <= p2D.y <= area_y)
|
||||
def is_facing_correctly(normal):
|
||||
return not backface_test or (not normal) or vec_fwd.dot(normal) <= 0
|
||||
def is_not_occluded(point):
|
||||
return not occlusion_test or ((ray := Point_to_Ray(point, min_dist=clip_start, max_dist_offset=-max_dist_offset)) and not raycast_hit_any(ray, ray_ignore_backface_sources))
|
||||
|
||||
def is_visible(point:Point, normal:Normal=None):
|
||||
return is_inside_area(point) and is_facing_correctly(normal) and is_not_occluded(point)
|
||||
|
||||
return is_visible
|
||||
|
||||
def gen_is_nonvisible(self, *args, **kwargs):
|
||||
is_visible = self.gen_is_visible(*args, **kwargs)
|
||||
def is_nonvisible(*args, **kwargs):
|
||||
return not is_visible(*args, **kwargs)
|
||||
return is_nonvisible
|
||||
|
||||
def is_visible(self, point:Point, normal:Normal=None, bbox_factor_override=None, dist_offset_override=None, occlusion_test_override=None, backface_test_override=None):
|
||||
backface_test = options['selection backface test'] if backface_test_override is None else backface_test_override
|
||||
occlusion_test = options['selection occlusion test'] if occlusion_test_override is None else occlusion_test_override
|
||||
bbox_factor = options['visible bbox factor'] if bbox_factor_override is None else bbox_factor_override
|
||||
dist_offset = options['visible dist offset'] if dist_offset_override is None else dist_offset_override
|
||||
max_dist_offset = self.sources_bbox.get_min_dimension() * bbox_factor + dist_offset
|
||||
|
||||
# find where point projects to screen
|
||||
p2D = self.Point_to_Point2D(point)
|
||||
if not p2D: return False
|
||||
if not (0 <= p2D.x <= self.actions.size.x) or not (0 <= p2D.y <= self.actions.size.y): return False
|
||||
|
||||
# compute ray through projection point
|
||||
ray = self.Point_to_Ray(point, min_dist=self.drawing.space.clip_start, max_dist_offset=-max_dist_offset)
|
||||
if not ray: return False
|
||||
|
||||
# run backfacing test if applicable
|
||||
if backface_test and normal and normal.dot(ray.d) >= 0: return False
|
||||
|
||||
# run occlusion test if applicable
|
||||
if occlusion_test and self._raycast_hit_any(ray, self.ray_ignore_backface_sources()): return False
|
||||
|
||||
# point is visible!
|
||||
return True
|
||||
|
||||
def is_nonvisible(self, *args, **kwargs):
|
||||
return not self.is_visible(*args, **kwargs)
|
||||
|
||||
def visibility_preset_normal(self):
|
||||
options['visible bbox factor'] = 0.001
|
||||
options['visible dist offset'] = 0.1
|
||||
self.get_accel_visible()
|
||||
|
||||
def visibility_preset_tiny(self):
|
||||
options['visible bbox factor'] = 0.0
|
||||
options['visible dist offset'] = 0.0004
|
||||
self.get_accel_visible()
|
||||
|
||||
|
||||
###################################################
|
||||
# normal check
|
||||
|
||||
@CallGovernor.limit(time_limit=0.25)
|
||||
def normal_check(self):
|
||||
if not options['warning normal check']: return # user wishes not to do this check :(
|
||||
if self._warned_bad_normals: return # already warned this session
|
||||
if not self._detected_bad_normals: return # no bad normals detected
|
||||
|
||||
# _,hn,_,_ = self.raycast_sources_mouse()
|
||||
# vd = self.Point2D_to_Direction(self.actions.mouse)
|
||||
# if not hn: return # did not hit source mesh
|
||||
# if vd.dot(hn) < 0: return # facing correct direction (opposite of viewing direction)
|
||||
|
||||
self._warned_bad_normals = True # only warn once
|
||||
|
||||
message = ['\n'.join([
|
||||
'One of the sources has inward facing normals.',
|
||||
'Inward facing normals will cause new geometry to be created incorrectly or to prevent it from being selected.',
|
||||
'',
|
||||
'Possible fix: exit RetopoFlow, switch to Edit Mode on the source mesh, recalculate normals, then try RetopoFlow again.',
|
||||
])]
|
||||
|
||||
self.alert_user(
|
||||
title='Source(s) with inverted normals',
|
||||
message='\n\n'.join(message),
|
||||
level='warning',
|
||||
)
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import bpy
|
||||
|
||||
from mathutils import Matrix, Vector
|
||||
from bpy_extras.view3d_utils import (
|
||||
location_3d_to_region_2d,
|
||||
region_2d_to_vector_3d,
|
||||
region_2d_to_location_3d,
|
||||
region_2d_to_origin_3d,
|
||||
)
|
||||
|
||||
from ...config.options import options
|
||||
from ...addon_common.common.debug import dprint
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.maths import Point, Vec, Direction, Normal
|
||||
from ...addon_common.common.maths import Ray, XForm, Plane
|
||||
from ...addon_common.common.maths import Point2D, Vec2D, Direction2D
|
||||
from ...addon_common.common.decorators import blender_version_wrapper
|
||||
|
||||
|
||||
class RetopoFlow_Spaces:
|
||||
'''
|
||||
converts entities between screen space and world space
|
||||
|
||||
Note: if 2D is not specified, then it is a 1D or 3D entity (whichever is applicable)
|
||||
'''
|
||||
|
||||
def update_clip_settings(self, *, rescale=True):
|
||||
if options['clip auto adjust']:
|
||||
# adjust clipping settings
|
||||
view_origin = self.drawing.get_view_origin(orthographic_distance=1000)
|
||||
view_focus = self.actions.r3d.view_location
|
||||
bbox = self.sources_bbox
|
||||
closest = bbox.closest_Point(view_origin)
|
||||
farthest = bbox.farthest_Point(view_origin)
|
||||
self.drawing.space.clip_start = max(
|
||||
options['clip auto start min'],
|
||||
(view_origin - closest).length * options['clip auto start mult'],
|
||||
)
|
||||
self.drawing.space.clip_end = min(
|
||||
options['clip auto end max'],
|
||||
(view_origin - farthest).length * options['clip auto end mult'],
|
||||
)
|
||||
# print(f'clip auto adjusting')
|
||||
# print(f' origin: {view_origin}')
|
||||
# print(f' focus: {view_focus}')
|
||||
# print(f' closest: {closest}')
|
||||
# print(f' farthest: {farthest}')
|
||||
# print(f' dist from origin to closest: {(view_origin - closest).length}')
|
||||
# print(f' dist from origin to farthest: {(view_origin - farthest).length}')
|
||||
# print(f' dist from origin to focus: {(view_origin - view_focus).length}')
|
||||
# print(f' clip_start: {self.drawing.space.clip_start}')
|
||||
# print(f' clip_end: {self.drawing.space.clip_end}')
|
||||
elif rescale:
|
||||
self.end_normalize(self.context)
|
||||
self.start_normalize()
|
||||
# self.unscale_from_unit_box()
|
||||
# self.scale_to_unit_box(
|
||||
# clip_override=options['clip override'],
|
||||
# clip_start=options['clip start override'],
|
||||
# clip_end=options['clip end override'],
|
||||
# )
|
||||
|
||||
|
||||
|
||||
def get_view_origin(self):
|
||||
# does not work in ORTHO
|
||||
view_loc = self.actions.r3d.view_location
|
||||
view_dist = self.actions.r3d.view_distance
|
||||
view_rot = self.actions.r3d.view_rotation
|
||||
view_cam = Point(view_loc + (view_rot @ Vector((0,0,view_dist))))
|
||||
return view_cam
|
||||
|
||||
def get_view_direction(self):
|
||||
view_rot = self.actions.r3d.view_rotation
|
||||
return Direction(view_rot @ Vector((0, 0, -1)))
|
||||
|
||||
def Point2D_to_Vec(self, xy:Point2D):
|
||||
if xy is None: return None
|
||||
v = region_2d_to_vector_3d(self.actions.region, self.actions.r3d, xy)
|
||||
if v is None: return None
|
||||
return Vec(v)
|
||||
|
||||
def Point2D_to_Direction(self, xy:Point2D):
|
||||
if xy is None: return None
|
||||
d = region_2d_to_vector_3d(self.actions.region, self.actions.r3d, xy)
|
||||
if d is None: return None
|
||||
return Direction(d)
|
||||
|
||||
def Point2D_to_Origin(self, xy:Point2D):
|
||||
if xy is None: return None
|
||||
o = region_2d_to_origin_3d(self.actions.region, self.actions.r3d, xy)
|
||||
if o is None: return None
|
||||
return Point(o)
|
||||
|
||||
def Point2D_to_Ray(self, xy:Point2D, *, min_dist=0.0):
|
||||
if xy is None: return None
|
||||
o, d = self.Point2D_to_Origin(xy), self.Point2D_to_Direction(xy)
|
||||
if o is None or d is None: return None
|
||||
return Ray(o, d, min_dist=min_dist)
|
||||
|
||||
def Point2D_to_Point(self, xy:Point2D, depth:float):
|
||||
r = self.Point2D_to_Ray(xy)
|
||||
if r is None or r.o is None or r.d is None or depth is None:
|
||||
# dprint(r)
|
||||
pass
|
||||
# dprint(depth)
|
||||
pass
|
||||
return None
|
||||
return r.eval(depth) # Point(r.o + depth * r.d)
|
||||
#return Point(region_2d_to_location_3d(self.actions.region, self.actions.r3d, xy, depth))
|
||||
|
||||
def Point2D_to_Plane(self, xy0:Point2D, xy1:Point2D):
|
||||
ray0,ray1 = self.Point2D_to_Ray(xy0),self.Point2D_to_Ray(xy1)
|
||||
o = ray0.o + ray0.d
|
||||
n = Normal((ray1.o + ray1.d - o).cross(ray0.d))
|
||||
return Plane(o, n)
|
||||
|
||||
def Point_to_Point2D(self, xyz:Point):
|
||||
if not xyz: return None
|
||||
xy = location_3d_to_region_2d(self.actions.region, self.actions.r3d, xyz)
|
||||
if xy is None: return None
|
||||
return Point2D(xy)
|
||||
|
||||
alerted_small_clip_start = False
|
||||
def Point_to_depth(self, xyz):
|
||||
'''
|
||||
computes the distance of point (xyz) from view camera
|
||||
'''
|
||||
|
||||
if not xyz: return None
|
||||
xy = self.Point_to_Point2D(xyz)
|
||||
if xy is None: return None
|
||||
oxyz = self.Point2D_to_Origin(xy)
|
||||
return (xyz - oxyz).length
|
||||
|
||||
def Point_to_Direction(self, xyz:Point):
|
||||
if not xyz: return None
|
||||
xy = location_3d_to_region_2d(self.actions.region, self.actions.r3d, xyz)
|
||||
return self.Point2D_to_Direction(xy)
|
||||
|
||||
# @profiler.function
|
||||
def Point_to_Ray(self, xyz:Point, min_dist=0, max_dist_offset=0):
|
||||
if not xyz: return None
|
||||
xy = location_3d_to_region_2d(self.actions.region, self.actions.r3d, xyz)
|
||||
if not xy: return None
|
||||
o = self.Point2D_to_Origin(xy)
|
||||
#return Ray.from_segment(o, xyz)
|
||||
d = self.Point2D_to_Vec(xy)
|
||||
if o is None or d is None: return None
|
||||
dist = (o - xyz).length
|
||||
return Ray(o, d, min_dist=min_dist, max_dist=dist+max_dist_offset)
|
||||
|
||||
def size2D_to_size(self, size2D:float, depth:float):
|
||||
# computes size of 3D object at distance (depth) as it projects to 2D size
|
||||
# TODO: there are more efficient methods of computing this!
|
||||
|
||||
# find center of screen
|
||||
xy = Vec2D((self.actions.region.width, self.actions.region.height)) * 0.5
|
||||
# note: scaling then unscaling helps with numerical instability when clip_start is small
|
||||
scale = 1000.0
|
||||
p3d0 = self.Point2D_to_Point(xy, depth)
|
||||
p3d1 = self.Point2D_to_Point(xy + Vec2D((0, scale * size2D)), depth)
|
||||
if not p3d0 or not p3d1: return None
|
||||
return (p3d0 - p3d1).length / scale
|
||||
|
||||
def size_to_size2D(self, size:float, xyz:Point):
|
||||
if not xyz: return None
|
||||
xy = self.Point_to_Point2D(xyz)
|
||||
if not xy: return None
|
||||
pt2D = self.Point_to_Point2D(xyz - self.Vec_up() * size)
|
||||
if not pt2D: return None
|
||||
return abs(xy.y - pt2D.y)
|
||||
|
||||
def Point2D_in_area(self, p2D):
|
||||
return p2D and (0 <= p2D.x <= self.actions.size.x) and (0 <= p2D.y <= self.actions.size.y)
|
||||
|
||||
|
||||
#############################################
|
||||
# return camera up and right vectors
|
||||
|
||||
def Vec_up(self):
|
||||
# TODO: remove invert!
|
||||
return self.actions.r3d.view_matrix.to_3x3().inverted_safe() @ Vector((0,1,0))
|
||||
|
||||
def Vec_right(self):
|
||||
# TODO: remove invert!
|
||||
return self.actions.r3d.view_matrix.to_3x3().inverted_safe() @ Vector((1,0,0))
|
||||
|
||||
def Vec_forward(self):
|
||||
# TODO: remove invert!
|
||||
return self.actions.r3d.view_matrix.to_3x3().inverted_safe() @ Vector((0,0,-1))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
# the order of these tools dictates the order tools show in UI
|
||||
from ..rftool_select.select import Select
|
||||
from ..rftool_contours.contours import Contours
|
||||
from ..rftool_polystrips.polystrips import PolyStrips
|
||||
from ..rftool_strokes.strokes import Strokes
|
||||
from ..rftool_patches.patches import Patches
|
||||
from ..rftool_polypen.polypen import PolyPen
|
||||
from ..rftool_knife.knife import Knife
|
||||
from ..rftool_loops.loops import Loops
|
||||
from ..rftool_tweak.tweak import Tweak
|
||||
from ..rftool_relax.relax import Relax
|
||||
|
||||
from ..rftool import RFTool
|
||||
|
||||
from ...config.options import options
|
||||
|
||||
class RetopoFlow_Tools:
|
||||
def setup_rftools(self):
|
||||
self.rftool = None
|
||||
self.rftools = [rftool(self) for rftool in RFTool.registry]
|
||||
self._rftool_return = None
|
||||
|
||||
def reset_rftool(self):
|
||||
self.rftool._reset()
|
||||
|
||||
def _select_rftool(self, rftool, *, reset=True, quick=False):
|
||||
assert rftool in self.rftools
|
||||
|
||||
# return if tool already set
|
||||
if rftool == self.rftool:
|
||||
if reset: self.reset_rftool()
|
||||
return False
|
||||
|
||||
self.rftool = rftool
|
||||
if reset:
|
||||
self.reset_rftool()
|
||||
self._update_rftool_ui()
|
||||
self.update_ui()
|
||||
if quick:
|
||||
self.rftool._callback('quickswitch start')
|
||||
return True
|
||||
|
||||
def _update_rftool_ui(self):
|
||||
rftool = self.rftool
|
||||
self.ui_main.getElementById(f'tool-{rftool.name.lower()}').checked = True
|
||||
self.ui_tiny.getElementById(f'ttool-{rftool.name.lower()}').checked = True
|
||||
self.ui_main.dirty(cause='changed tools', children=True)
|
||||
self.ui_tiny.dirty(cause='changed tools', children=True)
|
||||
|
||||
statusbar_keymap = self.substitute_keymaps(rftool.statusbar, wrap='', pre='', post=':', separator='/', onlyfirst=2)
|
||||
statusbar_keymap = statusbar_keymap.replace('\t', ' ')
|
||||
if self._rftool_return and self._rftool_return != rftool:
|
||||
statusbar = f'{self._rftool_return.name} → {rftool.name}: {statusbar_keymap}'
|
||||
else:
|
||||
statusbar = f'{rftool.name}: {statusbar_keymap}'
|
||||
self.context.workspace.status_text_set(statusbar)
|
||||
|
||||
def select_rftool(self, rftool, *, reset=True):
|
||||
self.rftool_return = None
|
||||
if self._select_rftool(rftool, reset=reset):
|
||||
# remember this tool as last used, so clicking diamond can start with this tool
|
||||
options['starting tool'] = rftool.name
|
||||
|
||||
def quick_select_rftool(self, rftool, *, reset=True):
|
||||
prev_tool = self.rftool
|
||||
if self._select_rftool(rftool, reset=reset, quick=True):
|
||||
self._rftool_return = prev_tool
|
||||
self._update_rftool_ui()
|
||||
|
||||
def quick_restore_rftool(self, *, reset=True):
|
||||
if not self._rftool_return: return
|
||||
if self.select_rftool(self._rftool_return, reset=reset):
|
||||
self._rftool_return = None
|
||||
self._update_rftool_ui()
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import gc
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import shutil
|
||||
import inspect
|
||||
from datetime import datetime
|
||||
import contextlib
|
||||
|
||||
|
||||
import urllib.request
|
||||
|
||||
import bpy
|
||||
|
||||
from ...addon_common.cookiecutter.cookiecutter import CookieCutter
|
||||
from ...addon_common.common.boundvar import BoundVar, BoundBool, BoundFloat, BoundString, BoundInt
|
||||
from ...addon_common.common.utils import delay_exec
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.blender import get_path_from_addon_root
|
||||
from ...addon_common.common.blender_preferences import get_preferences
|
||||
from ...addon_common.common.ui_core import UI_Element
|
||||
from ...addon_common.common.ui_styling import load_defaultstylings
|
||||
from ...addon_common.common.profiler import profiler
|
||||
|
||||
from ...config.options import (
|
||||
options, themes, visualization,
|
||||
retopoflow_urls, retopoflow_product, # these are needed for UI
|
||||
build_platform,
|
||||
platform_system, platform_node, platform_release, platform_version, platform_machine, platform_processor,
|
||||
)
|
||||
|
||||
|
||||
class RetopoFlow_UI:
|
||||
@CookieCutter.Exception_Callback
|
||||
def handle_exception(self, e):
|
||||
print(f'RF_UI.handle_exception: {e}')
|
||||
if False:
|
||||
for entry in inspect.stack():
|
||||
print(f' {entry}')
|
||||
message,h = Globals.debugger.get_exception_info_and_hash()
|
||||
message = '\n'.join(f'- {l}' for l in message.splitlines())
|
||||
self.alert_user(title='Exception caught', message=message, level='exception', msghash=h)
|
||||
self.rftool._reset()
|
||||
|
||||
|
||||
#################################
|
||||
# pie menu
|
||||
|
||||
def setup_pie_menu(self):
|
||||
path_pie_menu_html = get_path_from_addon_root('retopoflow', 'html', 'pie_menu.html')
|
||||
self.ui_pie_menu = UI_Element.fromHTMLFile(path_pie_menu_html)[0]
|
||||
self.ui_pie_menu.can_hover = False
|
||||
self.document.body.append_child(self.ui_pie_menu)
|
||||
|
||||
def show_pie_menu(self, options, fn_callback, highlighted=None, release=None, always_callback=False, rotate=0):
|
||||
if len(options) == 0: return
|
||||
self.pie_menu_rotation = rotate - 90
|
||||
self.pie_menu_callback = fn_callback
|
||||
self.pie_menu_options = list(options)
|
||||
self.pie_menu_highlighted = highlighted
|
||||
self.pie_menu_release = release or 'pie menu'
|
||||
self.pie_menu_always_callback = always_callback
|
||||
self.fsm.force_set_state('pie menu')
|
||||
|
||||
|
||||
#################################
|
||||
# ui
|
||||
|
||||
def blender_ui_set(self, scale_to_unit_box=True, add_rotate=True, hide_target=True):
|
||||
# print('RetopoFlow: blender_ui_set', 'scale_to_unit_box='+str(scale_to_unit_box), 'add_rotate='+str(add_rotate))
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
if scale_to_unit_box:
|
||||
self.start_normalize()
|
||||
self.scene_scale_set(1.0)
|
||||
self.viewaa_simplify()
|
||||
|
||||
if self.shading_type_get() in {'WIREFRAME', 'RENDERED'}:
|
||||
self.shading_type_set('SOLID')
|
||||
|
||||
self.gizmo_hide()
|
||||
|
||||
if get_preferences().system.use_region_overlap or options['hide panels no overlap']:
|
||||
ignore = None if options['hide header panel'] else {'header'}
|
||||
self.panels_hide(ignore=ignore)
|
||||
if options['hide overlays']:
|
||||
self.overlays_hide()
|
||||
self.blender_shading_update()
|
||||
self.quadview_hide()
|
||||
self.region_darken()
|
||||
self.header_text_set('RetopoFlow')
|
||||
self.statusbar_text_set('')
|
||||
if add_rotate: self.setup_rotate_about_active()
|
||||
if hide_target: self.hide_target()
|
||||
|
||||
def blender_shading_update(self):
|
||||
if options['override shading'] == 'off':
|
||||
self.shading_restore()
|
||||
return
|
||||
|
||||
# common optimizations
|
||||
self.shading_type_set(options['shading view'])
|
||||
self.shading_backface_set(options['shading backface culling'])
|
||||
self.shading_shadows_set(options['shading shadows'])
|
||||
self.shading_xray_set(options['shading xray'])
|
||||
self.shading_cavity_set(options['shading cavity'])
|
||||
self.shading_outline_set(options['shading outline'])
|
||||
|
||||
# theme-based optimizations
|
||||
matcap = None
|
||||
if options['override shading'] == 'light':
|
||||
self.shading_color_set(options['shading color light'])
|
||||
self.shading_colortype_set(options['shading colortype'])
|
||||
matcap = options['shading matcap light']
|
||||
elif options['override shading'] == 'dark':
|
||||
self.shading_color_set(options['shading color dark'])
|
||||
self.shading_colortype_set(options['shading colortype'])
|
||||
matcap = options['shading matcap dark']
|
||||
if matcap:
|
||||
if matcap not in bpy.context.preferences.studio_lights:
|
||||
path_rf_matcap = os.path.join(get_path_from_addon_root('matcaps'), matcap)
|
||||
print(f'RetopoFlow: Loading maptcap {matcap} {path_rf_matcap}')
|
||||
ret = bpy.context.preferences.studio_lights.load(path_rf_matcap, 'MATCAP')
|
||||
if not ret: matcap = None
|
||||
if matcap:
|
||||
self.shading_light_set(options['shading light'])
|
||||
self.shading_matcap_set(matcap)
|
||||
|
||||
def blender_ui_reset(self, *, ignore_panels=False):
|
||||
# IMPORTANT: changes here should also go in rf_blender_save.backup_recover()
|
||||
self.end_rotate_about_active()
|
||||
self.teardown_target()
|
||||
self.end_normalize(self.context)
|
||||
self._cc_blenderui_end(ignore=({'panels'} if ignore_panels else None))
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
|
||||
@contextlib.contextmanager
|
||||
def blender_ui_pause(self, *, ignore_panels=False):
|
||||
self.blender_ui_reset(ignore_panels=ignore_panels)
|
||||
yield None
|
||||
self.blender_ui_set()
|
||||
self.update_clip_settings(rescale=False)
|
||||
|
||||
def setup_ui_blender(self):
|
||||
self.blender_ui_set(scale_to_unit_box=False, add_rotate=False, hide_target=False)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def update_ui(self):
|
||||
if not hasattr(self, 'rftools_ui'): return
|
||||
autohide = options['tools autohide']
|
||||
changed = False
|
||||
for rftool in self.rftools_ui.keys():
|
||||
show = not autohide or (rftool == self.rftool)
|
||||
for ui_elem in self.rftools_ui[rftool]:
|
||||
if ui_elem.get_is_visible() == show: continue
|
||||
ui_elem.is_visible = show
|
||||
changed = True
|
||||
if changed:
|
||||
self.ui_options.dirty(cause='update', parent=True, children=True)
|
||||
|
||||
def update_ui_geometry(self):
|
||||
if not self.ui_geometry: return
|
||||
vis = self.ui_geometry.is_visible
|
||||
# TODO: FIX WORKAROUND HACK!
|
||||
# toggle visibility as workaround hack for relaying out table :(
|
||||
if vis: self.ui_geometry.is_visible = False
|
||||
self.ui_geometry.getElementById('geometry-verts').innerText = f'{self.rftarget.get_vert_count()}'
|
||||
self.ui_geometry.getElementById('geometry-edges').innerText = f'{self.rftarget.get_edge_count()}'
|
||||
self.ui_geometry.getElementById('geometry-faces').innerText = f'{self.rftarget.get_face_count()}'
|
||||
if vis: self.ui_geometry.is_visible = True
|
||||
|
||||
def minimize_geometry_window(self, target):
|
||||
if target.id != 'geometrydialog': return
|
||||
options['show geometry window'] = False
|
||||
self.ui_geometry.is_visible = False
|
||||
self.ui_geometry_min.is_visible = True
|
||||
self.ui_geometry_min.left = self.ui_geometry.left
|
||||
self.ui_geometry_min.top = self.ui_geometry.top
|
||||
self.document.force_clean(self.actions.context)
|
||||
def restore_geometry_window(self, target):
|
||||
if target.id != 'geometrydialog-minimized': return
|
||||
options['show geometry window'] = True
|
||||
self.ui_geometry.is_visible = True
|
||||
self.ui_geometry_min.is_visible = False
|
||||
self.ui_geometry.left = self.ui_geometry_min.left
|
||||
self.ui_geometry.top = self.ui_geometry_min.top
|
||||
self.update_ui_geometry()
|
||||
self.document.force_clean(self.actions.context)
|
||||
|
||||
def minimize_options_window(self, target):
|
||||
if target.id != 'optionsdialog': return
|
||||
options['show options window'] = False
|
||||
self.ui_options.is_visible = False
|
||||
self.ui_options_min.is_visible = True
|
||||
self.ui_options_min.left = self.ui_options.left
|
||||
self.ui_options_min.top = self.ui_options.top
|
||||
self.document.force_clean(self.actions.context)
|
||||
def restore_options_window(self, target):
|
||||
if target.id != 'optionsdialog-minimized': return
|
||||
options['show options window'] = True
|
||||
self.ui_options.is_visible = True
|
||||
self.ui_options_min.is_visible = False
|
||||
self.ui_options.left = self.ui_options_min.left
|
||||
self.ui_options.top = self.ui_options_min.top
|
||||
self.document.force_clean(self.actions.context)
|
||||
|
||||
def show_options_window(self):
|
||||
options['show options window'] = True
|
||||
self.ui_options.is_visible = True
|
||||
# self.ui_main.getElementById('show-options').disabled = True
|
||||
def hide_options_window(self):
|
||||
options['show options window'] = False
|
||||
self.ui_options.is_visible = False
|
||||
# self.ui_main.getElementById('show-options').disabled = False
|
||||
def options_window_visibility_changed(self):
|
||||
if self.ui_hide: return
|
||||
visible = self.ui_options.is_visible
|
||||
options['show options window'] = visible
|
||||
# self.ui_main.getElementById('show-options').disabled = visible
|
||||
|
||||
def show_main_ui_window(self):
|
||||
options['show main window'] = True
|
||||
self.ui_tiny.is_visible = False
|
||||
self.ui_main.is_visible = True
|
||||
def show_tiny_ui_window(self):
|
||||
options['show main window'] = False
|
||||
self.ui_tiny.is_visible = True
|
||||
self.ui_main.is_visible = False
|
||||
def update_main_ui_window(self):
|
||||
if self.ui_hide: return
|
||||
if self._ui_windows_updating: return
|
||||
pre = self._ui_windows_updating
|
||||
self._ui_windows_updating = True
|
||||
options['show main window'] = self.ui_main.is_visible
|
||||
if not options['show main window']:
|
||||
self.ui_tiny.is_visible = True
|
||||
self.ui_tiny.left = self.ui_main.left
|
||||
self.ui_tiny.top = self.ui_main.top
|
||||
# self.ui_tiny.clean()
|
||||
self._ui_windows_updating = pre
|
||||
def update_tiny_ui_window(self):
|
||||
if self.ui_hide: return
|
||||
if self._ui_windows_updating: return
|
||||
pre = self._ui_windows_updating
|
||||
self._ui_windows_updating = True
|
||||
options['show main window'] = not self.ui_tiny.is_visible
|
||||
if options['show main window']:
|
||||
self.ui_main.is_visible = True
|
||||
self.ui_main.left = self.ui_tiny.left
|
||||
self.ui_main.top = self.ui_tiny.top
|
||||
# self.ui_main.clean()
|
||||
self._ui_windows_updating = pre
|
||||
def update_main_tiny_ui_windows(self):
|
||||
if self.ui_hide: return
|
||||
|
||||
pre = self._ui_windows_updating
|
||||
self._ui_windows_updating = True
|
||||
self.ui_main.is_visible = options['show main window']
|
||||
self.ui_tiny.is_visible = not options['show main window']
|
||||
self._ui_windows_updating = pre
|
||||
|
||||
def setup_ui(self):
|
||||
# NOTE: lambda is needed on next line so that RF keymaps are bound!
|
||||
humanread = lambda x: self.actions.to_human_readable(x, sep=' / ')
|
||||
|
||||
self.hide_target()
|
||||
|
||||
# load ui.css
|
||||
self.reload_stylings()
|
||||
self.ui_hide = False
|
||||
|
||||
self._var_auto_hide_options = BoundBool('''options['tools autohide']''', on_change=self.update_ui)
|
||||
|
||||
rf_starting_tool = getattr(self, 'rf_starting_tool', None) or options['starting tool']
|
||||
|
||||
def setup_counts_ui():
|
||||
self.document.body.append_children(UI_Element.fromHTMLFile(get_path_from_addon_root('retopoflow', 'html', 'geometry.html')))
|
||||
self.ui_geometry = self.document.body.getElementById('geometrydialog')
|
||||
self.ui_geometry_min = self.document.body.getElementById('geometrydialog-minimized')
|
||||
self.ui_geometry.is_visible = options['show geometry window']
|
||||
self.ui_geometry_min.is_visible = not options['show geometry window']
|
||||
self.update_ui_geometry()
|
||||
|
||||
def setup_tiny_ui():
|
||||
nonlocal humanread
|
||||
self.ui_tiny = UI_Element.fromHTMLFile(get_path_from_addon_root('retopoflow', 'html', 'main_tiny.html'))[0]
|
||||
self.document.body.append_child(self.ui_tiny)
|
||||
|
||||
def setup_main_ui():
|
||||
nonlocal humanread
|
||||
self.ui_main = UI_Element.fromHTMLFile(get_path_from_addon_root('retopoflow', 'html', 'main_full.html'))[0]
|
||||
self.document.body.append_child(self.ui_main)
|
||||
|
||||
def setup_tool_buttons():
|
||||
ui_tools = self.ui_main.getElementById('tools')
|
||||
ui_ttools = self.ui_tiny.getElementById('ttools')
|
||||
def add_tool(rftool): # IMPORTANT: must be a fn so that local vars are unique and correctly captured
|
||||
nonlocal self, humanread # IMPORTANT: need this so that these are captured
|
||||
shortcut = humanread({rftool.shortcut})
|
||||
quick = humanread({rftool.quick_shortcut}) if rftool.quick_shortcut else ''
|
||||
title = f'{rftool.name}: {rftool.description}. Shortcut: {shortcut}.'
|
||||
if quick: title += f' Quick: {quick}.'
|
||||
val = f'{rftool.name.lower()}'
|
||||
ui_tools.append_child(UI_Element.fromHTML(
|
||||
f'<label title="{title}" class="tool">'
|
||||
f'''<input type="radio" id="tool-{val}" value="{val}" name="tool" class="tool" on_input="if this.checked: self.select_rftool(rftool)">'''
|
||||
f'<img src="{rftool.icon}" title="{title}">'
|
||||
f'<span title="{title}">{rftool.name}</span>'
|
||||
f'</label>'
|
||||
)[0])
|
||||
ui_ttools.append_child(UI_Element.fromHTML(
|
||||
f'<label title="{title}" class="ttool">'
|
||||
f'''<input type="radio" id="ttool-{val}" value="{val}" name="ttool" class="ttool" on_input="if this.checked: self.select_rftool(rftool)">'''
|
||||
f'<img src="{rftool.icon}" title="{title}">'
|
||||
f'</label>'
|
||||
)[0])
|
||||
for rftool in self.rftools: add_tool(rftool)
|
||||
|
||||
def setup_options():
|
||||
nonlocal self, humanread
|
||||
|
||||
self.document.defer_cleaning = True
|
||||
|
||||
self.document.body.append_children(UI_Element.fromHTMLFile(get_path_from_addon_root('retopoflow', 'html', 'options_dialog.html')))
|
||||
self.ui_options = self.document.body.getElementById('optionsdialog')
|
||||
self.ui_options_min = self.document.body.getElementById('optionsdialog-minimized')
|
||||
self.ui_options.is_visible = options['show options window']
|
||||
self.ui_options_min.is_visible = not options['show options window']
|
||||
|
||||
self.setup_pie_menu()
|
||||
|
||||
self.rftools_ui = {}
|
||||
for rftool in self.rftools:
|
||||
ui_elems = []
|
||||
def add_elem(ui_elem):
|
||||
if not ui_elem:
|
||||
return
|
||||
if type(ui_elem) is list:
|
||||
for ui in ui_elem:
|
||||
add_elem(ui)
|
||||
return
|
||||
ui_elems.append(ui_elem)
|
||||
self.ui_options.getElementById('options-contents').append_child(ui_elem)
|
||||
if rftool.ui_config:
|
||||
path_folder = os.path.dirname(inspect.getfile(rftool.__class__))
|
||||
path_html = os.path.join(path_folder, rftool.ui_config)
|
||||
ret = rftool.call_with_self_in_context(UI_Element.fromHTMLFile, path_html)
|
||||
add_elem(ret)
|
||||
ret = rftool._callback('ui setup')
|
||||
add_elem(ret)
|
||||
|
||||
self.rftools_ui[rftool] = ui_elems
|
||||
for ui_elem in ui_elems:
|
||||
self.ui_options.getElementById('options-contents').append_child(ui_elem)
|
||||
|
||||
# if options['show options window']:
|
||||
# self.show_options_window()
|
||||
# else:
|
||||
# self.hide_options_window()
|
||||
|
||||
self.document.defer_cleaning = False
|
||||
|
||||
|
||||
def setup_quit_ui():
|
||||
def hide_ui_quit():
|
||||
self.ui_quit.is_visible = False
|
||||
self.document.sticky_element = None
|
||||
self.document.clear_last_under()
|
||||
def mouseleave_event():
|
||||
if self.ui_quit.is_hovered: return
|
||||
hide_ui_quit()
|
||||
def key(e):
|
||||
if e.key in {'ESC', 'TAB'}: hide_ui_quit()
|
||||
if e.key in {'RET', 'NUMPAD_ENTER'}: self.done()
|
||||
|
||||
self.ui_quit = UI_Element.fromHTMLFile(get_path_from_addon_root('retopoflow', 'html', 'quit_dialog.html'))[0]
|
||||
self.ui_quit.is_visible = False
|
||||
self.document.body.append_child(self.ui_quit)
|
||||
|
||||
def setup_delete_ui():
|
||||
def hide_ui_delete():
|
||||
self.ui_delete.is_visible = False
|
||||
self.document.sticky_element = None
|
||||
self.document.clear_last_under()
|
||||
def mouseleave_event():
|
||||
if self.ui_delete.is_hovered: return
|
||||
hide_ui_delete()
|
||||
def key(e):
|
||||
if e.key == 'ESC': hide_ui_delete()
|
||||
def act(opt):
|
||||
self.delete_dissolve_collapse_option(opt)
|
||||
hide_ui_delete()
|
||||
|
||||
self.ui_delete = UI_Element.fromHTMLFile(get_path_from_addon_root('retopoflow', 'html', 'delete_dialog.html'))[0]
|
||||
self.ui_delete.is_visible = False
|
||||
self.document.body.append_child(self.ui_delete)
|
||||
|
||||
self._ui_windows_updating = True
|
||||
setup_main_ui()
|
||||
setup_tiny_ui()
|
||||
setup_tool_buttons()
|
||||
setup_options()
|
||||
setup_quit_ui()
|
||||
setup_delete_ui()
|
||||
setup_counts_ui()
|
||||
self.update_main_tiny_ui_windows()
|
||||
self._ui_windows_updating = False
|
||||
|
||||
for rftool in self.rftools:
|
||||
if rftool.name == rf_starting_tool:
|
||||
self.select_rftool(rftool)
|
||||
|
||||
self.ui_tools = self.document.body.getElementsByName('tool')
|
||||
self.update_ui()
|
||||
|
||||
def show_welcome_message(self):
|
||||
show = options['welcome'] or options['version update']
|
||||
if not show: return
|
||||
options['version update'] = False
|
||||
self.document.defer_cleaning = True
|
||||
self.helpsystem_open('welcome.md')
|
||||
self.document.defer_cleaning = False
|
||||
|
||||
def show_quit_dialog(self):
|
||||
w,h = self.actions.region.width,self.actions.region.height
|
||||
self.ui_quit.reposition(
|
||||
left = self.actions.mouse.x - 100,
|
||||
top = self.actions.mouse.y - h + 20,
|
||||
)
|
||||
self.ui_quit.is_visible = True
|
||||
self.document.focus(self.ui_quit)
|
||||
self.document.sticky_element = self.ui_quit
|
||||
|
||||
|
||||
def show_delete_dialog(self):
|
||||
if not self.any_selected():
|
||||
self.alert_user('No geometry selected to delete/dissolve', title='Delete/Dissolve')
|
||||
return
|
||||
|
||||
w,h = self.actions.region.width,self.actions.region.height
|
||||
self.ui_delete.reposition(
|
||||
left = self.actions.mouse.x - 100,
|
||||
top = self.actions.mouse.y - h + 20,
|
||||
)
|
||||
self.ui_delete.is_visible = True
|
||||
self.document.focus(self.ui_delete)
|
||||
self.document.sticky_element = self.ui_delete
|
||||
|
||||
# # The following is what is done with dialogs
|
||||
# self.document.force_clean(self.actions.context)
|
||||
# self.document.center_on_mouse(win)
|
||||
# self.document.sticky_element = win
|
||||
|
||||
def show_merge_dialog(self):
|
||||
if not self.any_selected():
|
||||
self.alert_user('No geometry selected to merge', title='Merge')
|
||||
return
|
||||
|
||||
w,h = self.actions.region.width,self.actions.region.height
|
||||
self.ui_delete.reposition(
|
||||
left = self.actions.mouse.x - 100,
|
||||
top = self.actions.mouse.y - h + 20,
|
||||
)
|
||||
self.ui_delete.is_visible = True
|
||||
self.document.focus(self.ui_delete)
|
||||
self.document.sticky_element = self.ui_delete
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import gc
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import inspect
|
||||
from datetime import datetime
|
||||
import contextlib
|
||||
from itertools import chain
|
||||
|
||||
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import bpy
|
||||
|
||||
from ...addon_common.cookiecutter.cookiecutter import CookieCutter
|
||||
from ...addon_common.common.blender import get_path_from_addon_root
|
||||
from ...addon_common.common.boundvar import BoundVar, BoundBool, BoundFloat, BoundString
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.inspect import ScopeBuilder
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.ui_core import UI_Element
|
||||
from ...addon_common.common.ui_styling import load_defaultstylings
|
||||
from ...addon_common.common.utils import delay_exec
|
||||
from ...addon_common.terminal.deepdebug import DeepDebug
|
||||
|
||||
from ...config.options import (
|
||||
options, themes, visualization,
|
||||
retopoflow_urls, retopoflow_product, retopoflow_files,
|
||||
build_platform,
|
||||
platform_system, platform_node, platform_release, platform_version, platform_machine, platform_processor,
|
||||
gpu_info,
|
||||
)
|
||||
|
||||
def get_environment_details():
|
||||
blender_version = '%d.%02d.%d' % bpy.app.version
|
||||
blender_branch = bpy.app.build_branch.decode('utf-8')
|
||||
blender_date = bpy.app.build_commit_date.decode('utf-8')
|
||||
|
||||
if retopoflow_product['git version']: build_info = f'RF git: {retopoflow_product["git version"]}'
|
||||
elif retopoflow_product['cgcookie built']:
|
||||
if retopoflow_product['github']: build_info = f'CG Cookie built for GitHub'
|
||||
elif retopoflow_product['blender market']: build_info = f'CG Cookie built for Blender Market'
|
||||
else: build_info = f'CG Cookie built for ??'
|
||||
else: build_info = f'Self built'
|
||||
|
||||
return [
|
||||
f'Environment:',
|
||||
f'',
|
||||
f'- RetopoFlow: {retopoflow_product["version"]}',
|
||||
f'- Build: {build_info}',
|
||||
f'- Blender: {blender_version} {blender_branch} {blender_date}',
|
||||
f'- Platform: {platform_system}, {platform_release}, {platform_version}, {platform_machine}, {platform_processor}',
|
||||
f'- GPU: {gpu_info}',
|
||||
f'- Timestamp: {datetime.today().isoformat(" ")}',
|
||||
f'',
|
||||
]
|
||||
|
||||
|
||||
def get_trace_details(undo_stack, msghash=None, message=None):
|
||||
trace_details = [f'Runtime:', f'']
|
||||
trace_details += [f'- Undo: {", ".join(undo_stack[:10])}']
|
||||
if msghash:
|
||||
trace_details += [f'- Error Hash: {msghash}']
|
||||
if message:
|
||||
trace_details += ['', 'Trace:', '']
|
||||
trace_details += [message]
|
||||
trace_details += ['']
|
||||
return trace_details
|
||||
|
||||
def get_debug_details():
|
||||
debug = DeepDebug.read()
|
||||
if not debug: return []
|
||||
return ['Debug:', ''] + debug.splitlines()
|
||||
|
||||
|
||||
|
||||
class RetopoFlow_UI_Alert:
|
||||
GitHub_checks = 0
|
||||
GitHub_limit = 10
|
||||
|
||||
@CookieCutter.Exception_Callback
|
||||
def handle_exception(self, e):
|
||||
print('RetopoFlow_UI_Alert.handle_exception', e)
|
||||
if False:
|
||||
for entry in inspect.stack():
|
||||
print(f' {entry}')
|
||||
message, h = Globals.debugger.get_exception_info_and_hash()
|
||||
message = '\n'.join(f'- {l}' for l in message.splitlines())
|
||||
self.alert_user(
|
||||
title='Exception caught',
|
||||
message=message,
|
||||
level='exception',
|
||||
msghash=h,
|
||||
)
|
||||
if hasattr(self, 'rftool'): self.rftool._reset()
|
||||
|
||||
def alert_user(self, message=None, title=None, level=None, msghash=None):
|
||||
scope = ScopeBuilder()
|
||||
|
||||
if not hasattr(self, '_msghashes'): self._msghashes = set()
|
||||
if not hasattr(self, 'alert_windows'): self.alert_windows = 0
|
||||
if msghash and msghash in self._msghashes: return # have already seen this error!!
|
||||
self._msghashes.add(msghash)
|
||||
|
||||
show_quit = False
|
||||
level = level.lower() if level else 'note'
|
||||
blender_version = '%d.%02d.%d' % bpy.app.version
|
||||
blender_branch = bpy.app.build_branch.decode('utf-8')
|
||||
blender_date = bpy.app.build_commit_date.decode('utf-8')
|
||||
darken = False
|
||||
|
||||
ui_checker = None
|
||||
ui_show = None
|
||||
message_orig = message
|
||||
report_details = ''
|
||||
msg_report = None
|
||||
issue_body_report = None
|
||||
|
||||
if title is None and self.rftool: title = self.rftool.name
|
||||
|
||||
def screenshot():
|
||||
ss_filename = retopoflow_files['screenshot filename']
|
||||
if getattr(bpy.data, 'filepath', ''):
|
||||
# loaded .blend file
|
||||
filepath = os.path.split(os.path.abspath(bpy.data.filepath))[0]
|
||||
filepath = os.path.join(filepath, ss_filename)
|
||||
else:
|
||||
# startup file
|
||||
filepath = os.path.abspath(ss_filename)
|
||||
bpy.ops.screen.screenshot(filepath=filepath)
|
||||
self.alert_user(message=f'Saved screenshot to "{filepath}"')
|
||||
def open_issues():
|
||||
bpy.ops.wm.url_open(url=retopoflow_urls['github issues'])
|
||||
def search():
|
||||
url = f'https://github.com/CGCookie/retopoflow/issues?q=is%3Aissue+{msghash}'
|
||||
bpy.ops.wm.url_open(url=url)
|
||||
def report():
|
||||
nonlocal issue_body_report
|
||||
nonlocal report_details
|
||||
|
||||
path = get_path_from_addon_root('help', 'issue_template_simple.md')
|
||||
issue_template = open(path, 'rt').read()
|
||||
data = {
|
||||
'title': f'{self.rftool.name}: {title}',
|
||||
'body': f'{issue_template}\n\n```\n{issue_body_report}\n```',
|
||||
}
|
||||
url = f'{retopoflow_urls["new github issue"]}?{urllib.parse.urlencode(data)}'
|
||||
bpy.ops.wm.url_open(url=url)
|
||||
|
||||
if msghash:
|
||||
ui_checker = UI_Element.DETAILS(classes='issue-checker', open=True)
|
||||
UI_Element.SUMMARY(innerText='Report an issue', parent=ui_checker)
|
||||
ui_label = UI_Element.ARTICLE(classes='mdown', parent=ui_checker)
|
||||
ui_buttons = UI_Element.DIV(parent=ui_checker, classes='action-buttons')
|
||||
|
||||
ui_label.set_markdown(mdown='Checking reported issues...')
|
||||
|
||||
def check_github():
|
||||
nonlocal win, ui_buttons
|
||||
buttons = 4
|
||||
try:
|
||||
if self.GitHub_checks < self.GitHub_limit:
|
||||
self.GitHub_checks += 1
|
||||
# attempt to see if this issue already exists!
|
||||
# note: limited to 60 requests/hour! see
|
||||
# https://developer.github.com/v3/#rate-limiting
|
||||
# https://developer.github.com/v3/search/#rate-limit
|
||||
|
||||
# make it unsecure to work around SSL issue
|
||||
# https://medium.com/@moreless/how-to-fix-python-ssl-certificate-verify-failed-97772d9dd14c
|
||||
import ssl
|
||||
if (not os.environ.get('PYTHONHTTPSVERIFY', '') and getattr(ssl, '_create_unverified_context', None)):
|
||||
ssl._create_default_https_context = ssl._create_unverified_context
|
||||
|
||||
url = "https://api.github.com/repos/CGCookie/retopoflow/issues?state=all"
|
||||
response = urllib.request.urlopen(url)
|
||||
text = response.read().decode('utf-8')
|
||||
issues = json.loads(text)
|
||||
exists,solved,issueurl = False,False,None
|
||||
for issue in issues:
|
||||
if msghash not in issue['body']: continue
|
||||
issueurl = issue['html_url']
|
||||
exists = True
|
||||
if issue['state'] == 'closed': solved = True
|
||||
if not exists:
|
||||
print('GitHub: Not reported, yet')
|
||||
ui_label.set_markdown(mdown='This issue does not appear to be reported, yet.\n\nPlease consider reporting it so we can fix it.')
|
||||
else:
|
||||
if not solved:
|
||||
print('GitHub: Already reported!')
|
||||
ui_label.set_markdown('This issue appears to have been reported already.\n\nClick Open button to see the current status.')
|
||||
else:
|
||||
print('GitHub: Already solved!')
|
||||
ui_label.set_markdown('This issue appears to have been solved already!\n\nAn updated RetopoFlow should fix this issue.')
|
||||
def go():
|
||||
bpy.ops.wm.url_open(url=issueurl)
|
||||
UI_Element.BUTTON(innerText='Open', on_mouseclick=go, title='Open this issue on the RetopoFlow Issue Tracker', classes='fifth-size', parent=ui_buttons)
|
||||
buttons = 5
|
||||
else:
|
||||
ui_label.set_markdown('Could not run the check.\n\nPlease consider reporting it so we can fix it.')
|
||||
except Exception as e:
|
||||
ui_label.set_markdown('Sorry, but we could not reach the RetopoFlow Isssues Tracker.\n\nClick the Similar button to search for similar issues.')
|
||||
pass
|
||||
print('Caught exception while trying to pull issues from GitHub')
|
||||
print(f'URL: "{url}"')
|
||||
print(e)
|
||||
# ignore for now
|
||||
pass
|
||||
size = 'fourth-size' if buttons==4 else 'fifth-size'
|
||||
UI_Element.BUTTON(innerText='Screenshot', classes=f'action {size}', parent=ui_buttons, on_mouseclick=screenshot, title='Save a screenshot of Blender')
|
||||
UI_Element.BUTTON(innerText='Similar', classes=f'action {size}', parent=ui_buttons, on_mouseclick=search, title='Search the RetopoFlow Issue Tracker for similar issues')
|
||||
UI_Element.BUTTON(innerText='All Issues', classes=f'action {size}', parent=ui_buttons, on_mouseclick=open_issues, title='Open RetopoFlow Issue Tracker')
|
||||
UI_Element.BUTTON(innerText='Report', classes=f'action {size}', parent=ui_buttons, on_mouseclick=report, title='Report a new issue on the RetopoFlow Issue Tracker')
|
||||
|
||||
executor = ThreadPoolExecutor()
|
||||
executor.submit(check_github)
|
||||
|
||||
msg_report = ''
|
||||
issue_body_report = ''
|
||||
if level in {'note'}:
|
||||
title = 'Note' + (f': {title}' if title else '')
|
||||
message = message or 'a note'
|
||||
elif level in {'warning'}:
|
||||
title = 'Warning' + (f': {title}' if title else '')
|
||||
darken = True
|
||||
elif level in {'error'}:
|
||||
title = 'Error' + (f': {title}' if title else '!')
|
||||
show_quit = True
|
||||
darken = True
|
||||
elif level in {'assert', 'exception'}:
|
||||
self.save_emergency() # make an emergency save!
|
||||
|
||||
if level == 'assert':
|
||||
title = 'Assert Error' + (f': {title}' if title else '!')
|
||||
desc = 'An internal assertion has failed.'
|
||||
else:
|
||||
title = 'Unhandled Exception Caught' + (f': {title}' if title else '!')
|
||||
desc = 'An unhandled exception was thrown.'
|
||||
|
||||
message = '\n'.join([
|
||||
desc,
|
||||
'This was unexpected.',
|
||||
'',
|
||||
'If this happens again, please report as bug so we can fix it.',
|
||||
])
|
||||
|
||||
undo_stack_actions = self.undo_stack_actions() if hasattr(self, 'undo_stack_actions') else []
|
||||
msg_report = '\n'.join(chain(
|
||||
get_environment_details(),
|
||||
get_trace_details(undo_stack_actions, msghash=msghash, message=message_orig),
|
||||
get_debug_details(),
|
||||
))
|
||||
issue_body_report = '\n'.join(chain(
|
||||
get_environment_details(),
|
||||
get_trace_details(undo_stack_actions, msghash=msghash, message=message_orig),
|
||||
))
|
||||
|
||||
show_quit = True
|
||||
darken = True
|
||||
else:
|
||||
title = level.upper() + (f': {title}' if title else '')
|
||||
message = message or 'a note'
|
||||
|
||||
@scope.capture_fn
|
||||
def close():
|
||||
nonlocal win
|
||||
if win.parent:
|
||||
self.document.body.delete_child(win)
|
||||
self.alert_windows -= 1
|
||||
if self.document.sticky_element == win:
|
||||
self.document.sticky_element = None
|
||||
self.document.clear_last_under()
|
||||
@scope.capture_fn
|
||||
def mouseleave_event(e):
|
||||
nonlocal win
|
||||
if not win.is_hovered: close()
|
||||
@scope.capture_fn
|
||||
def keypress_event(e):
|
||||
if e.key == 'ESC': close()
|
||||
@scope.capture_fn
|
||||
def quit():
|
||||
self.done()
|
||||
@scope.capture_fn
|
||||
def copy_to_clipboard():
|
||||
nonlocal msg_report
|
||||
try: bpy.context.window_manager.clipboard = msg_report
|
||||
except: pass
|
||||
|
||||
if self.alert_windows >= 5:
|
||||
return
|
||||
#self.exit = True
|
||||
|
||||
scope.capture_var('level')
|
||||
|
||||
win = UI_Element.fromHTMLFile(
|
||||
get_path_from_addon_root('retopoflow', 'html', 'alert_dialog.html'),
|
||||
frame_depth=2,
|
||||
**scope
|
||||
)[0]
|
||||
self.document.body.append_child(win)
|
||||
win.getElementById('alert-title').innerText = title
|
||||
win.getElementById('alert-message').set_markdown(mdown=message, frame_depth=2, **scope)
|
||||
if not msg_report and not ui_checker:
|
||||
win.getElementById('alert-details').is_visible = False
|
||||
if msg_report: win.getElementById('alert-report').innerText = msg_report
|
||||
else: win.getElementById('alert-report').is_visible = False
|
||||
if ui_checker: win.getElementById('alert-checker').append_child(ui_checker)
|
||||
else: win.getElementById('alert-checker').is_visible = False
|
||||
if not show_quit:
|
||||
win.getElementById('alert-close').style = 'width:100%'
|
||||
win.getElementById('alert-quit').is_visible = False
|
||||
|
||||
self.document.focus(win)
|
||||
self.alert_windows += 1
|
||||
if level in {'warning', 'note', None}:
|
||||
win.style = 'width:600px;'
|
||||
self.document.force_clean(self.actions.context)
|
||||
self.document.center_on_mouse(win)
|
||||
# self.document.sticky_element = win
|
||||
win.dirty(cause='new window', parent=False, children=True)
|
||||
else:
|
||||
self.document.force_clean(self.actions.context)
|
||||
self.document.center_on_mouse(win)
|
||||
win.dirty(cause='new window', parent=False, children=True)
|
||||
if level in {'note', None}:
|
||||
win.add_eventListener('on_mouseleave', mouseleave_event)
|
||||
win.add_eventListener('on_keypress', keypress_event)
|
||||
@@ -0,0 +1,93 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import copy
|
||||
from collections import namedtuple
|
||||
|
||||
from ...config.options import options
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.undostack import UndoStack
|
||||
|
||||
|
||||
class RetopoFlow_Undo:
|
||||
def init_undo(self):
|
||||
def create_state(action):
|
||||
nonlocal self
|
||||
self.instrument_write(action)
|
||||
return {
|
||||
'action': action,
|
||||
'tool': self.rftool,
|
||||
'rftarget': copy.deepcopy(self.rftarget),
|
||||
'grease_marks': copy.deepcopy(self.grease_marks),
|
||||
}
|
||||
|
||||
def restore_state(state, *, set_tool=True, reset_tool=True, instrument_action=None):
|
||||
nonlocal self
|
||||
|
||||
self.rftarget = state['rftarget']
|
||||
self.rftarget.rewrap()
|
||||
self.rftarget.dirty()
|
||||
self.rftarget_draw.replace_rfmesh(self.rftarget)
|
||||
self.grease_marks = state['grease_marks']
|
||||
|
||||
if set_tool: self.select_rftool(state['tool'], reset=reset_tool)
|
||||
elif reset_tool: self.reset_rftool()
|
||||
|
||||
if instrument_action: self.instrument_write(instrument_action)
|
||||
|
||||
tag_redraw_all('restoring state')
|
||||
|
||||
self._undostack = UndoStack(
|
||||
create_state,
|
||||
restore_state,
|
||||
max_size=options['undo depth'],
|
||||
)
|
||||
|
||||
@property
|
||||
def change_count(self):
|
||||
return self._undostack.changes
|
||||
|
||||
def undo_clear(self):
|
||||
self._undostack.clear()
|
||||
|
||||
def get_last_action(self):
|
||||
return self._undostack.top_key()
|
||||
|
||||
def undo_push(self, action, repeatable=False):
|
||||
self._undostack.push(action, repeatable=repeatable)
|
||||
|
||||
def undo_repush(self, action):
|
||||
### the restore method does not work?
|
||||
# self._undostack.restore(reset_tool=False)
|
||||
self._undostack.pop(reset_tool=False)
|
||||
self._undostack.push(action)
|
||||
|
||||
def undo_pop(self):
|
||||
self._undostack.pop(reset_tool=True, instrument_action='undo')
|
||||
|
||||
def undo_cancel(self):
|
||||
self._undostack.cancel(reset_tool=False, instrument_action='cancel (undo)')
|
||||
|
||||
def redo_pop(self):
|
||||
self._undostack.pop(undo=False, reset_tool=True, instrument_action='redo')
|
||||
|
||||
def undo_stack_actions(self):
|
||||
return self._undostack.keys() if hasattr(self, '_undostack') else []
|
||||
@@ -0,0 +1,196 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import os
|
||||
import re
|
||||
import bpy
|
||||
|
||||
from ..updater import updater
|
||||
|
||||
from ...addon_common.common.blender import get_path_from_addon_root
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.utils import delay_exec
|
||||
from ...addon_common.common.ui_styling import load_defaultstylings
|
||||
from ...addon_common.common.ui_core import UI_Element
|
||||
|
||||
from ...config.options import options, retopoflow_product, retopoflow_urls
|
||||
from ...config.keymaps import get_keymaps
|
||||
|
||||
class RetopoFlow_UpdaterSystem:
|
||||
@staticmethod
|
||||
def reload_stylings():
|
||||
load_defaultstylings()
|
||||
path = get_path_from_addon_root('config', 'ui.css')
|
||||
try:
|
||||
Globals.ui_draw.load_stylesheet(path)
|
||||
except AssertionError as e:
|
||||
# TODO: show proper dialog to user here!!
|
||||
print('could not load stylesheet "%s"' % path)
|
||||
print(e)
|
||||
Globals.ui_document.body.dirty(cause='Reloaded stylings', children=True)
|
||||
Globals.ui_document.body.dirty_styling()
|
||||
Globals.ui_document.body.dirty_flow()
|
||||
|
||||
def substitute_keymaps(self, mdown, wrap='`', pre='', post='', separator=', ', onlyfirst=None):
|
||||
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
|
||||
else: wrap_pre, wrap_post = wrap
|
||||
while True:
|
||||
m = re.search(r'{{(?P<action>[^}]+)}}', mdown)
|
||||
if not m: break
|
||||
action = { s.strip() for s in m.group('action').split(',') }
|
||||
sub = f'{pre}{wrap_pre}' + self.actions.to_human_readable(action, sep=f'{wrap_post}{separator}{wrap_pre}', onlyfirst=onlyfirst) + f'{wrap_post}{post}'
|
||||
mdown = mdown[:m.start()] + sub + mdown[m.end():]
|
||||
return mdown
|
||||
|
||||
def substitute_options(self, mdown, wrap='', pre='', post='', separator=', ', onlyfirst=None):
|
||||
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
|
||||
else: wrap_pre, wrap_post = wrap
|
||||
while True:
|
||||
m = re.search(r'{\[(?P<option>[^\]]+)\]}', mdown)
|
||||
if not m: break
|
||||
opts = { s.strip() for s in m.group('option').split(',') }
|
||||
sub = f'{pre}{wrap_pre}' + separator.join(str(options[opt]) for opt in opts) + f'{wrap_post}{post}'
|
||||
mdown = mdown[:m.start()] + sub + mdown[m.end():]
|
||||
return mdown
|
||||
|
||||
def substitute_python(self, mdown, wrap='', pre='', post=''):
|
||||
if type(wrap) is str: wrap_pre, wrap_post = wrap, wrap
|
||||
else: wrap_pre, wrap_post = wrap
|
||||
while True:
|
||||
m = re.search(r'{`(?P<python>[^`]+)`}', mdown)
|
||||
if not m: break
|
||||
pyret = eval(m.group('python'), globals(), locals())
|
||||
sub = f'{pre}{wrap_pre}{pyret}{wrap_post}{post}'
|
||||
mdown = mdown[:m.start()] + sub + mdown[m.end():]
|
||||
return mdown
|
||||
|
||||
def updater_open(self): #, mdown_path, done_on_esc=False, closeable=True, *args, **kwargs):
|
||||
newversion = ''
|
||||
keymaps = get_keymaps()
|
||||
def close():
|
||||
self.done()
|
||||
# e = self.document.body.getElementById('updaterdialog')
|
||||
# if not e: return
|
||||
# self.document.body.delete_child(e)
|
||||
def key(e):
|
||||
nonlocal keymaps, self
|
||||
if e.key == 'ESC':
|
||||
close()
|
||||
def blendermarket():
|
||||
bpy.ops.wm.url_open(url=retopoflow_urls['blender market'])
|
||||
def open_staging_folder():
|
||||
path = updater.stage_path
|
||||
if not os.path.exists(path):
|
||||
# updater stage path does not exist
|
||||
# attempt to create it
|
||||
os.makedirs(path)
|
||||
bpy.ops.wm.path_open(filepath=path)
|
||||
# path = opath
|
||||
# while not os.path.exists(path):
|
||||
# npath = os.path.abspath(os.path.join(path, '..'))
|
||||
# assert npath != path, f'Could not open {opath}'
|
||||
# path = npath
|
||||
|
||||
def done_updating(module_name, res=None):
|
||||
ui_updater.getElementById('select-version').is_visible = False
|
||||
if res is None:
|
||||
# success!
|
||||
ui_updater.getElementById('update-succeeded').is_visible = True
|
||||
ui_updater.getElementById('new-version').innerText = newversion
|
||||
else:
|
||||
# error
|
||||
ui_updater.getElementById('update-failed').is_visible = True
|
||||
ui_updater.getElementById('fail-version').innerText = newversion
|
||||
ui_updater.getElementById('fail-message').innerText = str(res)
|
||||
ui_updater.dirty(children=True)
|
||||
|
||||
def try_again():
|
||||
ui_updater.getElementById('update-succeeded').is_visible = False
|
||||
ui_updater.getElementById('update-failed').is_visible = False
|
||||
ui_updater.getElementById('select-version').is_visible = True
|
||||
ui_updater.dirty(children=True)
|
||||
|
||||
def load():
|
||||
nonlocal newversion
|
||||
uis = self.document.body.getElementsByName('version')
|
||||
tag = None
|
||||
for ui in uis:
|
||||
if ui.checked:
|
||||
tag = ui.value
|
||||
break
|
||||
assert tag
|
||||
if tag == 'none':
|
||||
# do nothing (should never get here, though)
|
||||
return
|
||||
elif tag == 'custom':
|
||||
# commit or branch specified
|
||||
tag = ui_updater.getElementById('custom').value
|
||||
newversion += tag
|
||||
link = f'https://github.com/CGCookie/retopoflow/archive/{tag}.zip'
|
||||
updater._update_ready = True
|
||||
updater._update_version = None
|
||||
updater._update_link = link
|
||||
else:
|
||||
# release/tag specified
|
||||
newversion += tag
|
||||
updater._update_ready = True
|
||||
updater.set_tag(tag)
|
||||
updater.run_update(callback=done_updating)
|
||||
|
||||
ui_updater = UI_Element.fromHTMLFile(get_path_from_addon_root('retopoflow', 'html', 'updater_dialog.html'))[0]
|
||||
ui_updater.getElementById('current-version').innerText = retopoflow_product['version']
|
||||
# ui_updater.getElementById('staging-folder').innerText = updater.stage_path
|
||||
ui_updater.getElementById('update-succeeded').is_visible = False
|
||||
ui_updater.getElementById('update-failed').is_visible = False
|
||||
self.document.body.append_child(ui_updater)
|
||||
self.document.body.dirty()
|
||||
|
||||
def version_on_input(this):
|
||||
if this is None: return
|
||||
if this.value == 'none':
|
||||
self.document.body.getElementById('load-version').disabled = this.checked
|
||||
|
||||
def set_option(value):
|
||||
for ui in ui_updater.getElementsByName('version'):
|
||||
if ui.value == value: ui.checked = True
|
||||
|
||||
def add_version_options(update_status):
|
||||
nonlocal version_on_input, set_option
|
||||
ui_versions = ui_updater.getElementById('version-options')
|
||||
ui_versions.append_children(UI_Element.fromHTML(
|
||||
f'''<label><input type="radio" name="version" value="none" on_input="version_on_input(this)" checked>Keep current version</label>'''
|
||||
))
|
||||
# for tag in updater._tags:
|
||||
# print(tag)
|
||||
for tag in updater.tags:
|
||||
tag = tag.replace('\n', '').replace('\r', '').replace('\t','')
|
||||
ui_versions.append_children(UI_Element.fromHTML(
|
||||
f'''<label><input type="radio" name="version" on_input="version_on_input(this)" value="{tag}">{tag}</label>'''
|
||||
))
|
||||
ui_versions.append_children(UI_Element.fromHTML(
|
||||
f'''<label class="option-custom"><input type="radio" name="version" on_input="version_on_input(this)" value="custom">Advanced: Commit / Branch</label><input type="text" id="custom" value="" title="Enter commit hash or branch name" on_focus="set_option('custom')">'''
|
||||
))
|
||||
|
||||
updater.include_branches = False
|
||||
updater.get_tags()
|
||||
add_version_options(None)
|
||||
#updater.check_for_update_now(add_version_options)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,486 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import sys
|
||||
import math
|
||||
import copy
|
||||
import json
|
||||
import time
|
||||
import random
|
||||
|
||||
from itertools import chain
|
||||
from queue import Queue
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import bpy
|
||||
import gpu
|
||||
import bmesh
|
||||
from bmesh.types import BMesh, BMVert, BMEdge, BMFace
|
||||
from mathutils import Matrix, Vector
|
||||
from mathutils.bvhtree import BVHTree
|
||||
from mathutils.kdtree import KDTree
|
||||
from mathutils.geometry import normal as compute_normal, intersect_point_tri
|
||||
|
||||
from ...addon_common.common import gpustate
|
||||
from ...addon_common.common import bmesh_render as bmegl
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.bmesh_render import triangulateFace, BufferedRender_Batch
|
||||
from ...addon_common.common.debug import dprint, Debugger
|
||||
from ...addon_common.common.decorators import stats_wrapper
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.hasher import hash_object, hash_bmesh
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.maths import (
|
||||
Point, Direction, Normal, Frame,
|
||||
Point2D, Vec2D, Direction2D,
|
||||
Ray, XForm, BBox, Plane,
|
||||
)
|
||||
from ...addon_common.common.utils import min_index
|
||||
|
||||
from ...config.options import options
|
||||
|
||||
from .rfmesh_wrapper import (
|
||||
BMElemWrapper, RFVert, RFEdge, RFFace, RFEdgeSequence,
|
||||
)
|
||||
|
||||
|
||||
|
||||
class RFMeshRender():
|
||||
'''
|
||||
RFMeshRender handles rendering RFMeshes.
|
||||
'''
|
||||
|
||||
cache = {}
|
||||
|
||||
create_count = 0
|
||||
delete_count = 0
|
||||
|
||||
@staticmethod
|
||||
# @profiler.function
|
||||
def new(rfmesh, opts, always_dirty=False):
|
||||
# TODO: REIMPLEMENT CACHING!!
|
||||
# HAD TO DISABLE THIS BECAUSE 2.83 AND 2.90 WOULD CRASH
|
||||
# WHEN RESTARTING RF. PROBABLY DUE TO HOLDING REFS TO
|
||||
# OLD DATA (CRASH DUE TO FREEING INVALID DATA??)
|
||||
|
||||
if False:
|
||||
if True: # with profiler.code('hashing object'):
|
||||
ho = hash_object(rfmesh.obj)
|
||||
if True: # with profiler.code('hashing bmesh'):
|
||||
hb = hash_bmesh(rfmesh.bme)
|
||||
h = (ho, hb)
|
||||
if h not in RFMeshRender.cache:
|
||||
RFMeshRender.creating = True
|
||||
RFMeshRender.cache[h] = RFMeshRender(rfmesh, opts)
|
||||
del RFMeshRender.creating
|
||||
rfmrender = RFMeshRender.cache[h]
|
||||
else:
|
||||
RFMeshRender.creating = True
|
||||
rfmrender = RFMeshRender(rfmesh, opts)
|
||||
del RFMeshRender.creating
|
||||
|
||||
rfmrender.always_dirty = always_dirty
|
||||
return rfmrender
|
||||
|
||||
# @profiler.function
|
||||
def __init__(self, rfmesh, opts):
|
||||
assert hasattr(RFMeshRender, 'creating'), (
|
||||
'Do not create new RFMeshRender directly!'
|
||||
'Use RFMeshRender.new()')
|
||||
|
||||
RFMeshRender.create_count += 1
|
||||
# print('RFMeshRender.__init__', RFMeshRender.create_count, RFMeshRender.delete_count)
|
||||
|
||||
# initially loading asynchronously?
|
||||
self.async_load = options['async mesh loading']
|
||||
self._is_loading = False
|
||||
self._is_loaded = False
|
||||
|
||||
self.load_verts = opts.get('load verts', True)
|
||||
self.load_edges = opts.get('load edges', True)
|
||||
self.load_faces = opts.get('load faces', True)
|
||||
|
||||
self.buf_data_queue = Queue()
|
||||
self.buf_matrix_model = rfmesh.xform.to_gpubuffer_Model()
|
||||
self.buf_matrix_inverse = rfmesh.xform.to_gpubuffer_Inverse()
|
||||
self.buf_matrix_normal = rfmesh.xform.to_gpubuffer_Normal()
|
||||
self.buffered_renders_static = []
|
||||
self.buffered_renders_dynamic = []
|
||||
self.split = None
|
||||
self.drawing = Globals.drawing
|
||||
|
||||
self.opts = {}
|
||||
self.replace_rfmesh(rfmesh)
|
||||
self.replace_opts(opts)
|
||||
|
||||
def __del__(self):
|
||||
RFMeshRender.delete_count += 1
|
||||
# print('RFMeshRender.__del__', self.rfmesh, RFMeshRender.create_count, RFMeshRender.delete_count)
|
||||
self.bmesh.free()
|
||||
if hasattr(self, 'buf_matrix_model'): del self.buf_matrix_model
|
||||
if hasattr(self, 'buf_matrix_inverse'): del self.buf_matrix_inverse
|
||||
if hasattr(self, 'buf_matrix_normal'): del self.buf_matrix_normal
|
||||
if hasattr(self, 'buffered_renders_static'): del self.buffered_renders_static
|
||||
if hasattr(self, 'buffered_renders_dynamic'): del self.buffered_renders_dynamic
|
||||
if hasattr(self, 'bmesh'): del self.bmesh
|
||||
if hasattr(self, 'rfmesh'): del self.rfmesh
|
||||
|
||||
# @profiler.function
|
||||
def replace_opts(self, opts):
|
||||
opts = dict(opts)
|
||||
opts['dpi mult'] = self.drawing.get_dpi_mult()
|
||||
if opts == self.opts: return
|
||||
self.opts = opts
|
||||
self.rfmesh_version = None
|
||||
|
||||
# @profiler.function
|
||||
def replace_rfmesh(self, rfmesh):
|
||||
self.rfmesh = rfmesh
|
||||
self.bmesh = rfmesh.bme
|
||||
self.rfmesh_version = None
|
||||
|
||||
def dirty(self):
|
||||
self.rfmesh_version = None
|
||||
|
||||
# @profiler.function
|
||||
def add_buffered_render(self, draw_type, data, static):
|
||||
batch = BufferedRender_Batch(draw_type)
|
||||
batch.buffer(data['vco'], data['vno'], data['sel'], data['warn'], data['pin'], data['seam'])
|
||||
if static: self.buffered_renders_static.append(batch)
|
||||
else: self.buffered_renders_dynamic.append(batch)
|
||||
|
||||
def split_visualization(self, verts=None, edges=None, faces=None):
|
||||
if not verts and not edges and not faces:
|
||||
self.split = None
|
||||
else:
|
||||
unwrap = BMElemWrapper._unwrap
|
||||
verts = { unwrap(v) for v in verts } if verts else set()
|
||||
edges = { unwrap(e) for e in edges } if edges else set()
|
||||
faces = { unwrap(f) for f in faces } if faces else set()
|
||||
edges.update(e for v in verts for e in v.link_edges)
|
||||
faces.update(f for e in edges for f in e.link_faces)
|
||||
verts.update(v for e in edges for v in e.verts)
|
||||
verts.update(v for f in faces for v in f.verts)
|
||||
edges.update(e for f in faces for e in f.edges)
|
||||
self.split = {
|
||||
'gathered static': False,
|
||||
'static verts': { v for v in self.bmesh.verts if v not in verts },
|
||||
'static edges': { e for e in self.bmesh.edges if e not in edges },
|
||||
'static faces': { f for f in self.bmesh.faces if f not in faces },
|
||||
'gathered dynamic': False,
|
||||
'dynamic verts': verts,
|
||||
'dynamic edges': edges,
|
||||
'dynamic faces': faces,
|
||||
}
|
||||
self.dirty()
|
||||
|
||||
# @profiler.function
|
||||
def _gather_data(self):
|
||||
if not self.split:
|
||||
self.buffered_renders_static = []
|
||||
self.buffered_renders_dynamic = []
|
||||
else:
|
||||
if not self.split['gathered dynamic']:
|
||||
self.buffered_renders_static = []
|
||||
self.split['gathered dynamic'] = True
|
||||
self.buffered_renders_dynamic = []
|
||||
|
||||
mirror_axes = self.rfmesh.mirror_mod.xyz if self.rfmesh.mirror_mod else []
|
||||
mirror_x = 'x' in mirror_axes
|
||||
mirror_y = 'y' in mirror_axes
|
||||
mirror_z = 'z' in mirror_axes
|
||||
|
||||
layer_pin = self.rfmesh.layer_pin
|
||||
|
||||
def gather(verts, edges, faces, static):
|
||||
vert_count = 100_000
|
||||
edge_count = 50_000
|
||||
face_count = 10_000
|
||||
|
||||
'''
|
||||
IMPORTANT NOTE: DO NOT USE PROFILER INSIDE THIS FUNCTION IF LOADING ASYNCHRONOUSLY!
|
||||
'''
|
||||
def sel(g):
|
||||
return 1.0 if g.select else 0.0
|
||||
def warn_vert(g):
|
||||
if mirror_x and g.co.x <= 0.0001: return 0.0
|
||||
if mirror_y and g.co.y >= -0.0001: return 0.0
|
||||
if mirror_z and g.co.z <= 0.0001: return 0.0
|
||||
return 0.0 if g.is_manifold and not g.is_boundary else 1.0
|
||||
def warn_edge(g):
|
||||
v0,v1 = g.verts
|
||||
if mirror_x and v0.co.x <= 0.0001 and v1.co.x <= 0.0001: return 0.0
|
||||
if mirror_y and v0.co.y >= -0.0001 and v1.co.y >= -0.0001: return 0.0
|
||||
if mirror_z and v0.co.z <= 0.0001 and v1.co.z <= 0.0001: return 0.0
|
||||
return 0.0 if g.is_manifold else 1.0
|
||||
def warn_face(g):
|
||||
return 1.0
|
||||
|
||||
def pin_vert(g):
|
||||
if not layer_pin: return 0.0
|
||||
return 1.0 if g[layer_pin] else 0.0
|
||||
def pin_edge(g):
|
||||
return 1.0 if all(pin_vert(v) for v in g.verts) else 0.0
|
||||
def pin_face(g):
|
||||
return 1.0 if all(pin_vert(v) for v in g.verts) else 0.0
|
||||
|
||||
def seam_vert(g):
|
||||
return 1.0 if any(e.seam for e in g.link_edges) else 0.0
|
||||
def seam_edge(g):
|
||||
return 1.0 if g.seam else 0.0
|
||||
def seam_face(g):
|
||||
return 0.0
|
||||
|
||||
try:
|
||||
time_start = time.time()
|
||||
|
||||
# NOTE: duplicating data rather than using indexing, otherwise
|
||||
# selection will bleed
|
||||
if True: # with profiler.code('gathering', enabled=not self.async_load):
|
||||
if self.load_faces:
|
||||
tri_faces = [(bmf, list(bmvs))
|
||||
for bmf in faces
|
||||
if bmf.is_valid and not bmf.hide
|
||||
for bmvs in triangulateFace(bmf.verts)
|
||||
]
|
||||
l = len(tri_faces)
|
||||
for i0 in range(0, l, face_count):
|
||||
i1 = min(l, i0 + face_count)
|
||||
face_data = {
|
||||
'vco': [ tuple(bmv.co) for bmf, verts in tri_faces[i0:i1] for bmv in verts ],
|
||||
'vno': [ tuple(bmv.normal) for bmf, verts in tri_faces[i0:i1] for bmv in verts ],
|
||||
'sel': [ sel(bmf) for bmf, verts in tri_faces[i0:i1] for _ in verts ],
|
||||
'warn': [ warn_face(bmf) for bmf, verts in tri_faces[i0:i1] for _ in verts ],
|
||||
'pin': [ pin_face(bmf) for bmf, verts in tri_faces[i0:i1] for _ in verts ],
|
||||
'seam': [ seam_face(bmf) for bmf, verts in tri_faces[i0:i1] for _ in verts ],
|
||||
'idx': None, # list(range(len(tri_faces)*3)),
|
||||
}
|
||||
if self.async_load:
|
||||
self.buf_data_queue.put((BufferedRender_Batch.TRIANGLES, face_data, static))
|
||||
tag_redraw_all('buffer update')
|
||||
else:
|
||||
self.add_buffered_render(BufferedRender_Batch.TRIANGLES, face_data, static)
|
||||
|
||||
if self.load_edges:
|
||||
edges = [bme for bme in edges if bme.is_valid and not bme.hide]
|
||||
l = len(edges)
|
||||
for i0 in range(0, l, edge_count):
|
||||
i1 = min(l, i0 + edge_count)
|
||||
edge_data = {
|
||||
'vco': [ tuple(bmv.co) for bme in edges[i0:i1] for bmv in bme.verts ],
|
||||
'vno': [ tuple(bmv.normal) for bme in edges[i0:i1] for bmv in bme.verts ],
|
||||
'sel': [ sel(bme) for bme in edges[i0:i1] for _ in bme.verts ],
|
||||
'warn': [ warn_edge(bme) for bme in edges[i0:i1] for _ in bme.verts ],
|
||||
'pin': [ pin_edge(bme) for bme in edges[i0:i1] for _ in bme.verts ],
|
||||
'seam': [ seam_edge(bme) for bme in edges[i0:i1] for _ in bme.verts ],
|
||||
'idx': None, # list(range(len(self.bmesh.edges)*2)),
|
||||
}
|
||||
if self.async_load:
|
||||
self.buf_data_queue.put((BufferedRender_Batch.LINES, edge_data, static))
|
||||
tag_redraw_all('buffer update')
|
||||
else:
|
||||
self.add_buffered_render(BufferedRender_Batch.LINES, edge_data, static)
|
||||
|
||||
if self.load_verts:
|
||||
verts = [bmv for bmv in verts if bmv.is_valid and not bmv.hide]
|
||||
l = len(verts)
|
||||
for i0 in range(0, l, vert_count):
|
||||
i1 = min(l, i0 + vert_count)
|
||||
vert_data = {
|
||||
'vco': [ tuple(bmv.co) for bmv in verts[i0:i1] ],
|
||||
'vno': [ tuple(bmv.normal) for bmv in verts[i0:i1] ],
|
||||
'sel': [ sel(bmv) for bmv in verts[i0:i1] ],
|
||||
'warn': [ warn_vert(bmv) for bmv in verts[i0:i1] ],
|
||||
'pin': [ pin_vert(bmv) for bmv in verts[i0:i1] ],
|
||||
'seam': [ seam_vert(bmv) for bmv in verts[i0:i1] ],
|
||||
'idx': None, # list(range(len(self.bmesh.verts))),
|
||||
}
|
||||
if self.async_load:
|
||||
self.buf_data_queue.put((BufferedRender_Batch.POINTS, vert_data, static))
|
||||
tag_redraw_all('buffer update')
|
||||
else:
|
||||
self.add_buffered_render(BufferedRender_Batch.POINTS, vert_data, static)
|
||||
|
||||
if self.async_load:
|
||||
self.buf_data_queue.put('done')
|
||||
|
||||
time_end = time.time()
|
||||
# print('RFMeshRender: Gather time: %0.2f' % (time_end - time_start))
|
||||
|
||||
except Exception as e:
|
||||
print('EXCEPTION WHILE GATHERING: ' + str(e))
|
||||
raise e
|
||||
|
||||
# self.bmesh.verts.ensure_lookup_table()
|
||||
for bmv in self.bmesh.verts:
|
||||
if bmv.link_faces:
|
||||
bmv.normal_update()
|
||||
# for bmelem in chain(self.bmesh.faces, self.bmesh.edges):
|
||||
# bmelem.normal_update()
|
||||
|
||||
self._is_loading = True
|
||||
self._is_loaded = False
|
||||
|
||||
# with profiler.code('Gathering data for RFMesh (%ssync)' % ('a' if self.async_load else '')):
|
||||
if not self.async_load:
|
||||
#print(f'RFMeshRender._gather: synchronous')
|
||||
#profiler.function(gather)()
|
||||
if not self.split:
|
||||
#print(f' v={len(self.bmesh.verts)} e={len(self.bmesh.edges)} f={len(self.bmesh.faces)}')
|
||||
gather(self.bmesh.verts, self.bmesh.edges, self.bmesh.faces, True)
|
||||
else:
|
||||
if not self.split['gathered static']:
|
||||
#print(f' sv={len(self.split["static verts"])} se={len(self.split["static edges"])} sf={len(self.split["static faces"])}')
|
||||
gather(self.split['static verts'], self.split['static edges'], self.split['static faces'], True)
|
||||
self.split['gathered static'] = True
|
||||
#print(f' dv={len(self.split["dynamic verts"])} de={len(self.split["dynamic edges"])} df={len(self.split["dynamic faces"])}')
|
||||
gather(self.split['dynamic verts'], self.split['dynamic edges'], self.split['dynamic faces'], False)
|
||||
else:
|
||||
#print(f'RFMeshRender._gather: asynchronous')
|
||||
#self._gather_submit = ThreadPoolExecutor.submit(gather)
|
||||
e = ThreadPoolExecutor()
|
||||
if not self.split:
|
||||
#print(f' v={len(self.bmesh.verts)} e={len(self.bmesh.edges)} f={len(self.bmesh.faces)}')
|
||||
e.submit(lambda : gather(self.bmesh.verts, self.bmesh.edges, self.bmesh.faces, True))
|
||||
else:
|
||||
if not self.split['gathered static']:
|
||||
#print(f' sv={len(self.split["static verts"])} se={len(self.split["static edges"])} sf={len(self.split["static faces"])}')
|
||||
e.submit(lambda : gather(self.split['static verts'], self.split['static edges'], self.split['static faces'], True))
|
||||
self.split['gathered static'] = True
|
||||
#print(f' dv={len(self.split["dynamic verts"])} de={len(self.split["dynamic edges"])} df={len(self.split["dynamic faces"])}')
|
||||
e.submit(lambda : gather(self.split['dynamic verts'], self.split['dynamic edges'], self.split['dynamic faces'], False))
|
||||
|
||||
# @profiler.function
|
||||
def clean(self):
|
||||
if not self.buf_data_queue.empty():
|
||||
tag_redraw_all('buffer update')
|
||||
while not self.buf_data_queue.empty():
|
||||
data = self.buf_data_queue.get()
|
||||
if data == 'done':
|
||||
self._is_loading = False
|
||||
self._is_loaded = True
|
||||
self.async_load = False
|
||||
else:
|
||||
self.add_buffered_render(*data)
|
||||
|
||||
try:
|
||||
# return if rfmesh hasn't changed
|
||||
self.rfmesh.clean()
|
||||
ver = self.rfmesh.get_version() if not self.always_dirty else None
|
||||
if self.rfmesh_version == ver:
|
||||
# profiler.add_note('--> is clean')
|
||||
return
|
||||
# profiler.add_note(
|
||||
# '--> versions: "%s",
|
||||
# "%s"' % (str(self.rfmesh_version),
|
||||
# str(ver))
|
||||
# )
|
||||
# make not dirty first in case bad things happen while drawing
|
||||
self.rfmesh_version = ver
|
||||
self._gather_data()
|
||||
except:
|
||||
Debugger.print_exception()
|
||||
# profiler.add_note('--> exception')
|
||||
pass
|
||||
|
||||
# profiler.add_note('--> passed through')
|
||||
|
||||
# @profiler.function
|
||||
def draw(
|
||||
self,
|
||||
view_forward, unit_scaling_factor,
|
||||
buf_matrix_target, buf_matrix_target_inv,
|
||||
buf_matrix_view, buf_matrix_view_invtrans,
|
||||
buf_matrix_proj,
|
||||
alpha_above, alpha_below,
|
||||
cull_backfaces, alpha_backface,
|
||||
draw_mirrored,
|
||||
symmetry=None, symmetry_view=None,
|
||||
symmetry_effect=0.0, symmetry_frame: Frame=None
|
||||
):
|
||||
self.clean()
|
||||
if not self.buffered_renders_static and not self.buffered_renders_dynamic: return
|
||||
|
||||
try:
|
||||
gpustate.depth_test('LESS_EQUAL')
|
||||
gpustate.depth_mask(False) # do not overwrite the depth buffer
|
||||
|
||||
opts = dict(self.opts)
|
||||
|
||||
opts['matrix model'] = self.rfmesh.xform.mx_p
|
||||
opts['matrix normal'] = self.rfmesh.xform.mx_n
|
||||
opts['matrix target'] = buf_matrix_target
|
||||
opts['matrix target inverse'] = buf_matrix_target_inv
|
||||
opts['matrix view'] = buf_matrix_view
|
||||
opts['matrix view normal'] = buf_matrix_view_invtrans
|
||||
opts['matrix projection'] = buf_matrix_proj
|
||||
opts['forward direction'] = view_forward
|
||||
opts['unit scaling factor'] = unit_scaling_factor
|
||||
|
||||
opts['symmetry'] = symmetry
|
||||
opts['symmetry frame'] = symmetry_frame
|
||||
opts['symmetry view'] = symmetry_view
|
||||
opts['symmetry effect'] = symmetry_effect
|
||||
opts['draw mirrored'] = draw_mirrored
|
||||
|
||||
bmegl.glSetDefaultOptions()
|
||||
|
||||
opts['no warning'] = not options['warn non-manifold']
|
||||
opts['no pinned'] = not options['show pinned']
|
||||
opts['no seam'] = not options['show seam']
|
||||
|
||||
opts['cull backfaces'] = cull_backfaces
|
||||
opts['alpha backface'] = alpha_backface
|
||||
opts['dpi mult'] = self.drawing.get_dpi_mult()
|
||||
mirror_axes = self.rfmesh.mirror_mod.xyz if self.rfmesh.mirror_mod else []
|
||||
for axis in mirror_axes: opts['mirror %s' % axis] = True
|
||||
|
||||
if not opts.get('no below', False):
|
||||
# draw geometry hidden behind
|
||||
# geometry below
|
||||
opts['depth test'] = 'GREATER'
|
||||
# opts['depth mask'] = False
|
||||
opts['poly hidden'] = 1 - alpha_below
|
||||
opts['poly mirror hidden'] = 1 - alpha_below
|
||||
opts['line hidden'] = 1 - alpha_below
|
||||
opts['line mirror hidden'] = 1 - alpha_below
|
||||
opts['point hidden'] = 1 - alpha_below
|
||||
opts['point mirror hidden'] = 1 - alpha_below
|
||||
for buffered_render in chain(self.buffered_renders_static, self.buffered_renders_dynamic):
|
||||
buffered_render.draw(opts)
|
||||
|
||||
# geometry above
|
||||
opts['depth test'] = 'LESS_EQUAL'
|
||||
# opts['depth mask'] = False
|
||||
opts['poly hidden'] = 1 - alpha_above
|
||||
opts['poly mirror hidden'] = 1 - alpha_above
|
||||
opts['line hidden'] = 1 - alpha_above
|
||||
opts['line mirror hidden'] = 1 - alpha_above
|
||||
opts['point hidden'] = 1 - alpha_above
|
||||
opts['point mirror hidden'] = 1 - alpha_above
|
||||
for buffered_render in chain(self.buffered_renders_static, self.buffered_renders_dynamic):
|
||||
buffered_render.draw(opts)
|
||||
|
||||
gpustate.depth_test('LESS_EQUAL')
|
||||
gpustate.depth_mask(True)
|
||||
except:
|
||||
Debugger.print_exception()
|
||||
pass
|
||||
@@ -0,0 +1,852 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import math
|
||||
|
||||
import bmesh
|
||||
from bmesh.types import BMesh, BMVert, BMEdge, BMFace
|
||||
from bmesh.utils import (
|
||||
edge_split, vert_splice, face_split,
|
||||
vert_collapse_edge, vert_dissolve, face_join,
|
||||
face_vert_separate,
|
||||
)
|
||||
from bmesh.ops import dissolve_verts, dissolve_edges, dissolve_faces
|
||||
from mathutils import Vector
|
||||
|
||||
from ...addon_common.common.utils import iter_pairs
|
||||
from ...addon_common.common.debug import dprint
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.maths import (
|
||||
triangle2D_det, triangle2D_area,
|
||||
segment2D_intersection,
|
||||
Vec2D, Point, Point2D, Vec, Direction, Normal,
|
||||
)
|
||||
|
||||
from ...config.options import options
|
||||
|
||||
|
||||
'''
|
||||
BMElemWrapper wraps BMverts, BMEdges, BMFaces to automagically handle
|
||||
world-to-local and local-to-world transformations.
|
||||
|
||||
Must override any property that can be set (TODO: find more elegant
|
||||
way to handle this!) and function that returns a BMVert, BMEdge, or
|
||||
BMFace. All functions and read-only properties are handled with
|
||||
__getattr__().
|
||||
|
||||
user-writable properties:
|
||||
|
||||
BMVert: co, normal
|
||||
BMEdge: seam, smooth
|
||||
BMFace: material_index, normal, smooth
|
||||
common: hide, index. select, tag
|
||||
|
||||
NOTE: RFVert, RFEdge, RFFace do NOT mark RFMesh as dirty!
|
||||
'''
|
||||
|
||||
|
||||
class BMElemWrapper:
|
||||
@staticmethod
|
||||
def wrap(rftarget):
|
||||
BMElemWrapper.rftarget = rftarget
|
||||
BMElemWrapper.xform = rftarget.xform
|
||||
BMElemWrapper.l2w_point = rftarget.xform.l2w_point
|
||||
BMElemWrapper.w2l_point = rftarget.xform.w2l_point
|
||||
BMElemWrapper.l2w_normal = rftarget.xform.l2w_normal
|
||||
BMElemWrapper.w2l_normal = rftarget.xform.w2l_normal
|
||||
BMElemWrapper.symmetry_real = rftarget.symmetry_real
|
||||
BMElemWrapper.mirror_mod = rftarget.mirror_mod
|
||||
|
||||
@staticmethod
|
||||
def _unwrap(bmelem):
|
||||
try: return bmelem.bmelem
|
||||
except: return bmelem
|
||||
|
||||
def __init__(self, bmelem):
|
||||
self.bmelem = bmelem
|
||||
|
||||
def __repr__(self):
|
||||
return f'<{"" if self.bmelem.is_valid else "XXX_"}{type(self).__name__}: {repr(self.bmelem)}>'
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.bmelem)
|
||||
|
||||
def __eq__(self, other):
|
||||
if other is None:
|
||||
return False
|
||||
if isinstance(other, BMElemWrapper):
|
||||
return self.bmelem == other.bmelem
|
||||
return self.bmelem == other
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
@property
|
||||
def hide(self):
|
||||
return self.bmelem.hide
|
||||
|
||||
@hide.setter
|
||||
def hide(self, v):
|
||||
self.bmelem.hide = v
|
||||
|
||||
@property
|
||||
def index(self):
|
||||
return self.bmelem.index
|
||||
|
||||
@index.setter
|
||||
def index(self, v):
|
||||
self.bmelem.index = v
|
||||
|
||||
@property
|
||||
def select(self):
|
||||
return self.bmelem.select and not self.bmelem.hide
|
||||
|
||||
@select.setter
|
||||
def select(self, v):
|
||||
self.bmelem.select = v
|
||||
|
||||
@property
|
||||
def unselect(self):
|
||||
return not self.bmelem.select and not self.bmelem.hide
|
||||
|
||||
@property
|
||||
def tag(self):
|
||||
return self.bmelem.tag
|
||||
|
||||
@tag.setter
|
||||
def tag(self, v):
|
||||
self.bmelem.tag = v
|
||||
|
||||
def __getattr__(self, k):
|
||||
if k in self.__dict__:
|
||||
return getattr(self, k)
|
||||
return getattr(self.bmelem, k)
|
||||
|
||||
|
||||
class RFVert(BMElemWrapper):
|
||||
@staticmethod
|
||||
def get_link_edges(rfverts):
|
||||
return { RFEdge(bme) for bmv in rfverts for bme in bmv.bmelem.link_edges }
|
||||
|
||||
@staticmethod
|
||||
def get_link_faces(rfverts):
|
||||
return { RFFace(bmf) for bmv in rfverts for bmf in bmv.bmelem.link_faces }
|
||||
|
||||
@property
|
||||
def co(self):
|
||||
return self.l2w_point(self.bmelem.co)
|
||||
|
||||
@co.setter
|
||||
def co(self, co):
|
||||
if not self.bmelem.is_valid: return
|
||||
if any(math.isnan(v) for v in co): return
|
||||
# assert not any(math.isnan(v) for v in co), f'Setting RFVert.co to {co}'
|
||||
if options['show pinned'] and options['pin enabled'] and self.pinned: return
|
||||
if options['show seam'] and options['pin seam'] and self.seam: return
|
||||
co = self.symmetry_real(co, to_world=False)
|
||||
# # the following does not work well, because new verts have co=(0,0,0)
|
||||
# mm = BMElemWrapper.mirror_mod
|
||||
# if mm.use_clip:
|
||||
# rft = BMElemWrapper.rftarget
|
||||
# th = mm.symmetry_threshold * rft.unit_scaling_factor / 2.0
|
||||
# ox,oy,oz = self.bmelem.co
|
||||
# nx,ny,nz = (mm.x and abs(ox) <= th),(mm.y and abs(oy) <= th),(mm.z and abs(oz) <= th)
|
||||
# if nx or ny or nz:
|
||||
# co = rft.snap_to_symmetry(co, mm._symmetry, to_world=False, from_world=False)
|
||||
self.bmelem.co = co
|
||||
|
||||
@property
|
||||
def pinned(self):
|
||||
return bool(self.bmelem[self.rftarget.layer_pin])
|
||||
@pinned.setter
|
||||
def pinned(self, v):
|
||||
self.bmelem[self.rftarget.layer_pin] = 1 if bool(v) else 0
|
||||
|
||||
@property
|
||||
def seam(self):
|
||||
return any(e.seam for e in self.bmelem.link_edges)
|
||||
|
||||
@property
|
||||
def normal(self):
|
||||
return self.l2w_normal(self.bmelem.normal)
|
||||
|
||||
@normal.setter
|
||||
def normal(self, norm):
|
||||
self.bmelem.normal = self.w2l_normal(norm)
|
||||
|
||||
@property
|
||||
def co_normal(self):
|
||||
return (self.co, self.normal)
|
||||
|
||||
@co_normal.setter
|
||||
def co_normal(self, co_normal):
|
||||
self.co, self.normal = co_normal
|
||||
|
||||
@property
|
||||
def link_edges(self):
|
||||
return [RFEdge(bme) for bme in self.bmelem.link_edges]
|
||||
|
||||
@property
|
||||
def link_faces(self):
|
||||
return [RFFace(bmf) for bmf in self.bmelem.link_faces]
|
||||
|
||||
def is_on_symmetry_plane(self):
|
||||
mm = BMElemWrapper.mirror_mod
|
||||
th = mm.symmetry_threshold * BMElemWrapper.rftarget.unit_scaling_factor / 2.0
|
||||
x,y,z = self.bmelem.co
|
||||
if mm.x and abs(x) <= th: return True
|
||||
if mm.y and abs(y) <= th: return True
|
||||
if mm.z and abs(z) <= th: return True
|
||||
return False
|
||||
|
||||
def is_on_boundary(self, symmetry_as_boundary=False):
|
||||
'''
|
||||
similar to is_boundary property, but optionally discard symmetry boundaries
|
||||
'''
|
||||
if not symmetry_as_boundary:
|
||||
if self.is_on_symmetry_plane(): return False
|
||||
return self.bmelem.is_boundary
|
||||
|
||||
#############################################
|
||||
|
||||
def share_edge(self, other):
|
||||
if not self.is_valid or not other.is_valid: return False
|
||||
bmv0 = BMElemWrapper._unwrap(self)
|
||||
bmv1 = BMElemWrapper._unwrap(other)
|
||||
return any(bmv1 in bme.verts for bme in bmv0.link_edges if bme.is_valid)
|
||||
|
||||
def shared_edge(self, other):
|
||||
if not self.is_valid or not other.is_valid: return False
|
||||
bmv0 = BMElemWrapper._unwrap(self)
|
||||
bmv1 = BMElemWrapper._unwrap(other)
|
||||
bme = next((bme for bme in bmv0.link_edges if bme.is_valid and bmv1 in bme.verts), None)
|
||||
return RFEdge(bme) if bme else None
|
||||
|
||||
def share_face(self, other):
|
||||
if not self.is_valid or not other.is_valid: return False
|
||||
bmv0 = BMElemWrapper._unwrap(self)
|
||||
bmv1 = BMElemWrapper._unwrap(other)
|
||||
return any(bmv1 in bmf.verts for bmf in bmv0.link_faces if bmf.is_valid)
|
||||
|
||||
def shared_faces(self, other):
|
||||
if not self.is_valid or not other.is_valid: return False
|
||||
bmv0 = BMElemWrapper._unwrap(self)
|
||||
bmv1 = BMElemWrapper._unwrap(other)
|
||||
return [RFFace(bmf) for bmf in bmv0.link_faces if bmf.is_valid and bmv1 in bmf.verts]
|
||||
|
||||
def face_separate(self, f):
|
||||
if not (self.is_valid and f and f.is_valid): return None
|
||||
bmv = BMElemWrapper._unwrap(self)
|
||||
bmf = BMElemWrapper._unwrap(f)
|
||||
new_bmv = face_vert_separate(bmf, bmv)
|
||||
return RFVert(new_bmv)
|
||||
|
||||
def merge(self, other):
|
||||
if not (self.is_valid and other.is_valid):
|
||||
if self.is_valid: return self
|
||||
if other.is_valid: return other
|
||||
return None
|
||||
|
||||
try:
|
||||
bmv0 = BMElemWrapper._unwrap(self)
|
||||
bmv1 = BMElemWrapper._unwrap(other)
|
||||
vert_splice(bmv1, bmv0)
|
||||
return RFVert(bmv0)
|
||||
except Exception as e:
|
||||
print(f'Caught Exception while trying to merge')
|
||||
print(e)
|
||||
print(f'Will try more robust merge')
|
||||
return self.merge_robust(other)
|
||||
|
||||
def merge_robust(self, other):
|
||||
if not (self.is_valid and other.is_valid):
|
||||
if self.is_valid: return self
|
||||
if other.is_valid: return other
|
||||
return None
|
||||
|
||||
rftarget = self.rftarget
|
||||
|
||||
if self.share_edge(other):
|
||||
bmv = self.shared_edge(other).collapse()
|
||||
rftarget.remove_duplicate_bmfaces(bmv)
|
||||
rftarget.clean_duplicate_bmedges(bmv)
|
||||
return bmv
|
||||
|
||||
if not self.share_face(other):
|
||||
bmv = self.merge(other)
|
||||
rftarget.remove_duplicate_bmfaces(bmv)
|
||||
rftarget.clean_duplicate_bmedges(bmv)
|
||||
return bmv
|
||||
|
||||
bmfs = self.shared_faces(other)
|
||||
for bmf in bmfs: bmf.split(self, other)
|
||||
rftarget.remove_duplicate_bmfaces(self)
|
||||
rftarget.clean_duplicate_bmedges(self)
|
||||
bmv = self.shared_edge(other).collapse()
|
||||
rftarget.remove_duplicate_bmfaces(bmv)
|
||||
rftarget.clean_duplicate_bmedges(bmv)
|
||||
return bmv
|
||||
|
||||
def dissolve(self):
|
||||
bmv = BMElemWrapper._unwrap(self)
|
||||
vert_dissolve(bmv)
|
||||
|
||||
def compute_normal(self):
|
||||
return Normal.average(f.compute_normal() for f in self.link_faces)
|
||||
|
||||
|
||||
class RFEdge(BMElemWrapper):
|
||||
@staticmethod
|
||||
def get_verts(rfedges):
|
||||
bmvs = { bmv for bme in rfedges for bmv in bme.bmelem.verts }
|
||||
return { RFVert(bmv) for bmv in bmvs }
|
||||
|
||||
@property
|
||||
def seam(self):
|
||||
return self.bmelem.seam
|
||||
|
||||
@seam.setter
|
||||
def seam(self, v):
|
||||
self.bmelem.seam = v
|
||||
|
||||
@property
|
||||
def smooth(self):
|
||||
return self.bmelem.smooth
|
||||
|
||||
@smooth.setter
|
||||
def smooth(self, v):
|
||||
self.bmelem.smooth = v
|
||||
|
||||
def first_vert(self):
|
||||
return RFVert(self.bmelem.verts[0])
|
||||
|
||||
def other_vert(self, bmv):
|
||||
bmv = self._unwrap(bmv)
|
||||
o = self.bmelem.other_vert(bmv)
|
||||
if o is None:
|
||||
return None
|
||||
return RFVert(o)
|
||||
|
||||
def share_vert(self, bme):
|
||||
if not self.is_valid or not bme.is_valid: return False
|
||||
bme = self._unwrap(bme)
|
||||
return any(v in bme.verts for v in self.bmelem.verts if v.is_valid)
|
||||
|
||||
def shared_vert(self, bme):
|
||||
if not self.is_valid or not bme.is_valid: return None
|
||||
bme = self._unwrap(bme)
|
||||
verts = [v for v in self.bmelem.verts if v.is_valid and v in bme.verts]
|
||||
if not verts:
|
||||
return None
|
||||
return RFVert(verts[0])
|
||||
|
||||
def nonshared_vert(self, bme):
|
||||
if not self.is_valid or not bme.is_valid: return None
|
||||
bme = self._unwrap(bme)
|
||||
verts = [v for v in self.bmelem.verts if v.is_valid and v not in bme.verts]
|
||||
if len(verts) != 1:
|
||||
return None
|
||||
return RFVert(verts[0])
|
||||
|
||||
def share_face(self, bme):
|
||||
if not self.is_valid or not bme.is_valid: return False
|
||||
bme = self._unwrap(bme)
|
||||
return any(f in bme.link_faces for f in self.bmelem.link_faces)
|
||||
|
||||
def shared_faces(self, bme):
|
||||
if not self.is_valid or not bme.is_valid: return set()
|
||||
bme = self._unwrap(bme)
|
||||
return {
|
||||
RFFace(f)
|
||||
for f in (set(self.bmelem.link_faces) & set(bme.link_faces))
|
||||
if f.is_valid
|
||||
}
|
||||
|
||||
@property
|
||||
def verts(self):
|
||||
bmv0, bmv1 = self.bmelem.verts
|
||||
return (RFVert(bmv0), RFVert(bmv1))
|
||||
|
||||
@property
|
||||
def link_faces(self):
|
||||
return [RFFace(bmf) for bmf in self.bmelem.link_faces]
|
||||
|
||||
def get_left_right_link_faces(self):
|
||||
v0, v1 = self.bmelem.verts
|
||||
bmfl, bmfr = None, None
|
||||
if len(self.bmelem.link_faces) == 2:
|
||||
bmfl, bmfr = self.bmelem.link_faces
|
||||
elif len(self.bmelem.link_faces) == 1:
|
||||
bmfl = next(iter(self.bmelem.link_faces))
|
||||
else:
|
||||
return (None, None)
|
||||
|
||||
for lv0, lv1 in iter_pairs(bmfl.verts, True):
|
||||
if lv0 == v0 and lv1 == v1:
|
||||
# correct orientation!
|
||||
break
|
||||
else:
|
||||
# swap left and right faces
|
||||
bmfl, bmfr = bmfr, bmfl
|
||||
|
||||
if bmfl:
|
||||
bmfl = RFFace(bmfl)
|
||||
if bmfr:
|
||||
bmfr = RFFace(bmfr)
|
||||
return (bmfl, bmfr)
|
||||
|
||||
#############################################
|
||||
|
||||
def compute_normal(self):
|
||||
return Normal.average(bmf.normal for bmf in self.link_faces)
|
||||
|
||||
def calc_length(self):
|
||||
v0, v1 = self.bmelem.verts
|
||||
return (self.l2w_point(v0.co) - self.l2w_point(v1.co)).length
|
||||
|
||||
@property
|
||||
def length(self):
|
||||
return self.calc_length()
|
||||
|
||||
def calc_center(self):
|
||||
v0, v1 = self.bmelem.verts
|
||||
return self.l2w_point((v0.co + v1.co) / 2)
|
||||
|
||||
def vector(self, from_vert=None, to_vert=None):
|
||||
v0, v1 = self.verts
|
||||
if from_vert:
|
||||
if v1 == from_vert: v0, v1 = v1, v0
|
||||
assert v0 == from_vert
|
||||
elif to_vert:
|
||||
if v0 == to_vert: v0, v1 = v1, v0
|
||||
assert v1 == to_vert
|
||||
return v1.co - v0.co
|
||||
|
||||
def vector2D(self, Point_to_Point2D, from_vert=None, to_vert=None):
|
||||
v0, v1 = self.verts
|
||||
if from_vert:
|
||||
if v1 == from_vert: v0, v1 = v1, v0
|
||||
assert v0 == from_vert
|
||||
elif to_vert:
|
||||
if v0 == to_vert: v0, v1 = v1, v0
|
||||
assert v1 == to_vert
|
||||
return Point_to_Point2D(v1.co) - Point_to_Point2D(v0.co)
|
||||
|
||||
def direction(self, from_vert=None, to_vert=None):
|
||||
return Direction(self.vector(from_vert=from_vert, to_vert=to_vert))
|
||||
|
||||
def perpendicular(self):
|
||||
d = self.vector()
|
||||
n = self.normal()
|
||||
return Direction(d.cross(n))
|
||||
|
||||
@staticmethod
|
||||
def get_direction(bme):
|
||||
v0, v1 = bme.verts
|
||||
return Direction(v1.co - v0.co)
|
||||
|
||||
#############################################
|
||||
|
||||
def get_next_edge_in_strip(self, rfvert):
|
||||
r'''
|
||||
given self=A and bmv=B, return C
|
||||
|
||||
o-----o-----o... o-----o-----o...
|
||||
| | | | | |
|
||||
o--A--B--C--o... o--A--B--C--o...
|
||||
| | | | |\
|
||||
o-----o-----o... o-----o o...
|
||||
\|
|
||||
o...
|
||||
crawl dir: ======>
|
||||
|
||||
left : "normal" case, where B is part of 4 touching quads
|
||||
right: here, find the edge with the direction most similarly
|
||||
pointing in same direction
|
||||
'''
|
||||
bmv = self._unwrap(rfvert)
|
||||
assert bmv in self.bmelem.verts, "Vert not part of Edge"
|
||||
|
||||
link_faces = list(self.bmelem.link_faces)
|
||||
link_edges = [bme for bme in bmv.link_edges if bme != self.bmelem]
|
||||
|
||||
# for details, see: https://github.com/CGCookie/retopoflow/issues/554#issuecomment-408185805
|
||||
|
||||
if len(link_faces) == 0:
|
||||
if len(link_edges) != 1: return None
|
||||
bme = link_edges[0]
|
||||
if len(bme.link_faces) != 0: return None
|
||||
return RFEdge(bme)
|
||||
|
||||
if len(link_faces) == 1:
|
||||
bmf0 = link_faces[0]
|
||||
lbme = [bme for bme in link_edges if len(bme.link_faces) == 1]
|
||||
lbme = [bme for bme in lbme if bmf0 not in bme.link_faces]
|
||||
lbme = [bme for bme in lbme if any(bme0 == bme1 for bme0 in bmf0.edges for bmf1 in bme.link_faces for bme1 in bmf1.edges)]
|
||||
if len(lbme) != 1: return None
|
||||
return RFEdge(lbme[0])
|
||||
|
||||
if len(link_faces) == 2 and len(bmv.link_faces) == 4 and len(bmv.link_edges) == 4:
|
||||
# bmv is part of 4 touching quads and all quads are touching
|
||||
# (left figure above)
|
||||
# find bme that does not share a face with self
|
||||
for bme in rfvert.link_edges:
|
||||
if len(bme.link_faces) != 2: continue
|
||||
if not (set(bme.link_faces) & set(link_faces)):
|
||||
return bme
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
#############################################
|
||||
|
||||
def split(self, vert=None, fac=0.5):
|
||||
bme = BMElemWrapper._unwrap(self)
|
||||
bmv = BMElemWrapper._unwrap(vert) or bme.verts[0]
|
||||
bme_new, bmv_new = edge_split(bme, bmv, fac)
|
||||
return RFEdge(bme_new), RFVert(bmv_new)
|
||||
|
||||
def collapse(self):
|
||||
bme = BMElemWrapper._unwrap(self)
|
||||
bmv0, bmv1 = bme.verts
|
||||
del_faces = [f for f in bme.link_faces if len(f.verts) == 3]
|
||||
for bmf in del_faces:
|
||||
self.rftarget.bme.faces.remove(bmf)
|
||||
bmesh.ops.collapse(self.rftarget.bme, edges=[bme], uvs=True)
|
||||
return RFVert(bmv0 if bmv0.is_valid else bmv1)
|
||||
|
||||
# # not working
|
||||
# def separate(self, face):
|
||||
# bme = BMElemWrapper._unwrap(self)
|
||||
# bmf = BMElemWrapper._unwrap(face)
|
||||
# loops = list(bme.link_loops)
|
||||
# floops = [loop for loop in loops if loop.face == bmf]
|
||||
# print(f'{bmf=} {loops=} {floops=}')
|
||||
# loop = next(iter(floops))
|
||||
# bmv0 = bmesh.utils.loop_separate(loop)
|
||||
# return RFVert(bmv0)
|
||||
|
||||
|
||||
class RFFace(BMElemWrapper):
|
||||
@staticmethod
|
||||
def get_verts(rffaces):
|
||||
bmvs = { bmv for bmf in rffaces for bmv in bmf.bmelem.verts }
|
||||
return { RFVert(bmv) for bmv in bmvs }
|
||||
|
||||
@property
|
||||
def material_index(self):
|
||||
return self.bmelem.material_index
|
||||
|
||||
@material_index.setter
|
||||
def material_index(self, v):
|
||||
self.bmelem.material_index = v
|
||||
|
||||
@property
|
||||
def normal(self):
|
||||
return self.l2w_normal(self.bmelem.normal)
|
||||
|
||||
@normal.setter
|
||||
def normal(self, v):
|
||||
self.bmelem.normal = self.w2l_normal(v)
|
||||
|
||||
@property
|
||||
def smooth(self):
|
||||
return self.bmelem.smooth
|
||||
|
||||
@smooth.setter
|
||||
def smooth(self, v):
|
||||
self.bmelem.smooth = v
|
||||
|
||||
@property
|
||||
def edges(self):
|
||||
return [RFEdge(bme) for bme in self.bmelem.edges]
|
||||
|
||||
def share_edge(self, other):
|
||||
bmes = set(self._unwrap(other).edges)
|
||||
return any(e in bmes for e in self.bmelem.edges)
|
||||
|
||||
def shared_edge(self, other):
|
||||
edges = set(self.bmelem.edges)
|
||||
for bme in other.bmelem.edges:
|
||||
if bme in edges:
|
||||
return RFEdge(bme)
|
||||
return None
|
||||
|
||||
def opposite_edge(self, e):
|
||||
if len(self.bmelem.edges) != 4:
|
||||
return None
|
||||
e = self._unwrap(e)
|
||||
for i, bme in enumerate(self.bmelem.edges):
|
||||
if bme == e:
|
||||
return RFEdge(self.bmelem.edges[(i + 2) % 4])
|
||||
return None
|
||||
|
||||
def neighbor_edges(self, e):
|
||||
e = self._unwrap(e)
|
||||
l = len(self.bmelem.edges)
|
||||
for i, bme in enumerate(self.bmelem.edges):
|
||||
if bme == e:
|
||||
return (
|
||||
RFEdge(self.bmelem.edges[(i - 1) % l]),
|
||||
RFEdge(self.bmelem.edges[(i + 1) % l])
|
||||
)
|
||||
return None
|
||||
|
||||
@property
|
||||
def verts(self):
|
||||
return [RFVert(bmv) for bmv in self.bmelem.verts]
|
||||
|
||||
def get_vert_co(self):
|
||||
return [self.l2w_point(bmv.co) for bmv in self.bmelem.verts]
|
||||
|
||||
def get_vert_normal(self):
|
||||
return [self.l2w_normal(bmv.normal) for bmv in self.bmelem.verts]
|
||||
|
||||
def is_quad(self):
|
||||
return len(self.bmelem.verts) == 4
|
||||
|
||||
def is_triangle(self):
|
||||
return len(self.bmelem.verts) == 3
|
||||
|
||||
def center(self):
|
||||
return Point.average(self.l2w_point(bmv.co) for bmv in self.bmelem.verts)
|
||||
|
||||
#############################################
|
||||
|
||||
def compute_normal(self):
|
||||
''' computes normal based on verts '''
|
||||
# TODO: should use loop rather than verts?
|
||||
an = Vec((0,0,0))
|
||||
vs = list(self.bmelem.verts)
|
||||
bmv1,bmv2 = vs[-2],vs[-1]
|
||||
v1 = bmv2.co - bmv1.co
|
||||
for bmv in vs:
|
||||
bmv0,bmv1,bmv2 = bmv1,bmv2,bmv
|
||||
v0,v1 = -v1,bmv2.co-bmv1.co
|
||||
an = an + v0.cross(v1)
|
||||
return self.l2w_normal(Normal(an))
|
||||
|
||||
def is_flipped(self):
|
||||
fn = self.w2l_normal(self.compute_normal())
|
||||
vs = list(self.bmelem.verts)
|
||||
return any(v.normal.dot(fn) <= 0 for v in vs)
|
||||
|
||||
def overlap2D(self, other, Point_to_Point2D):
|
||||
return self.overlap2D_center(other, Point_to_Point2D)
|
||||
|
||||
def overlap2D_center(self, other, Point_to_Point2D):
|
||||
verts0 = list(map(Point_to_Point2D, [v.co for v in self.bmelem.verts]))
|
||||
verts1 = list(
|
||||
map(Point_to_Point2D, [v.co for v in self._unwrap(other).verts]))
|
||||
center0 = sum(map(Vec2D, verts0), Vec2D((0, 0))) / len(verts0)
|
||||
center1 = sum(map(Vec2D, verts1), Vec2D((0, 0))) / len(verts1)
|
||||
radius0 = sum((v - center0).length for v in verts0) / len(verts0)
|
||||
radius1 = sum((v - center1).length for v in verts1) / len(verts1)
|
||||
ratio = 1 - (center0 - center1).length / (radius0 + radius1)
|
||||
return max(0, ratio)
|
||||
|
||||
def overlap2D_Sutherland_Hodgman(self, other, Point_to_Point2D):
|
||||
'''
|
||||
computes area in image space of overlap between self and other
|
||||
this is done by clipping other to self by iterating through all of
|
||||
edges in self and clipping to the "inside" half-space.
|
||||
Sutherland-Hodgman Algorithm:
|
||||
https://en.wikipedia.org/wiki/Sutherland%E2%80%93Hodgman_algorithm
|
||||
'''
|
||||
|
||||
# NOTE: assumes self and other are convex! (not a terrible assumption)
|
||||
|
||||
verts0 = list(map(Point_to_Point2D, [v.co for v in self.bmelem.verts]))
|
||||
verts1 = list(
|
||||
map(Point_to_Point2D, [v.co for v in self._unwrap(other).verts]))
|
||||
|
||||
for v00, v01 in zip(verts0, verts0[1:] + verts0[:1]):
|
||||
# other polygon (verts1) by line v00-v01
|
||||
len1 = len(verts1)
|
||||
sides = [triangle2D_det(v00, v01, v1) <= 0 for v1 in verts1]
|
||||
intersections = [
|
||||
segment2D_intersection(v00, v01, v10, v11)
|
||||
for v10, v11 in zip(verts1, verts1[1:] + verts1[:1])
|
||||
]
|
||||
nverts1 = []
|
||||
for i0 in range(len1):
|
||||
i1 = (i0 + 1) % len1
|
||||
v10, v11 = verts1[i0], verts1[i1]
|
||||
s10, s11 = sides[i0], sides[i1]
|
||||
|
||||
if s10 and s11:
|
||||
# both outside. might intersect
|
||||
if intersections[i0]:
|
||||
nverts1 += [intersections[i0]]
|
||||
elif not s11:
|
||||
if s10:
|
||||
# v10 is outside, v11 is inside
|
||||
if intersections[i0]:
|
||||
nverts1 += [intersections[i0]]
|
||||
nverts1 += [v11]
|
||||
verts1 = nverts1
|
||||
|
||||
if len(verts1) < 3:
|
||||
return 0
|
||||
v0 = verts1[0]
|
||||
return sum(
|
||||
triangle2D_area(v0, v1, v2)
|
||||
for v1, v2 in zip(verts1[1:-1], verts1[2:])
|
||||
)
|
||||
|
||||
def merge(self, other):
|
||||
# find vert of other that is closest to self's v0
|
||||
verts0, verts1 = list(self.bmelem.verts), list(other.bmelem.verts)
|
||||
l = len(verts0)
|
||||
assert l == len(verts1), 'RFFaces must have same vert count'
|
||||
self.rftarget.bme.faces.remove(self._unwrap(other))
|
||||
offset = min(range(l), key=lambda i: (
|
||||
verts1[i].co - verts0[0].co).length)
|
||||
# assuming verts are in same rotational order (should be)
|
||||
for i0 in range(l):
|
||||
i1 = (i0 + offset) % l
|
||||
bme = next((
|
||||
bme
|
||||
for bme in verts0[i0].link_edges
|
||||
if verts1[i1] in bme.verts
|
||||
), None)
|
||||
if bme:
|
||||
# issue #372
|
||||
# TODO: handle better
|
||||
# dprint('bme: ' + str(bme))
|
||||
pass
|
||||
pass
|
||||
else:
|
||||
vert_splice(verts1[i1], verts0[i0])
|
||||
# for v in verts0:
|
||||
# self.rftarget.clean_duplicate_bmedges(v)
|
||||
|
||||
#############################################
|
||||
|
||||
def split(self, vert_a, vert_b, coords=[]):
|
||||
bmf = BMElemWrapper._unwrap(self)
|
||||
bmva = BMElemWrapper._unwrap(vert_a)
|
||||
bmvb = BMElemWrapper._unwrap(vert_b)
|
||||
coords = [BMElemWrapper.w2l_point(c) for c in coords]
|
||||
bmf_new, bml_new = face_split(bmf, bmva, bmvb, coords=coords)
|
||||
return RFFace(bmf_new)
|
||||
|
||||
def shatter(self):
|
||||
working = [ self ]
|
||||
ret = set()
|
||||
|
||||
while working:
|
||||
bmf = working.pop()
|
||||
if not bmf.is_valid: continue
|
||||
ret.add(bmf)
|
||||
# see if one bmv connects to another
|
||||
touched_bmvs, path = set(), []
|
||||
def find_exit(bmv0):
|
||||
nonlocal touched_bmvs, path, bmf
|
||||
touched_bmvs.add(bmv0)
|
||||
path.append(bmv0)
|
||||
for bme in bmv0.link_edges:
|
||||
if bme.link_faces: continue # not a potential edge
|
||||
bmv1 = bme.other_vert(bmv0)
|
||||
if bmv1 in touched_bmvs: continue # already seen bmv1 (loop?)
|
||||
if bmf in bmv1.link_faces:
|
||||
path += [bmv1]
|
||||
return True
|
||||
if find_exit(bmv1): return True
|
||||
path.pop() # working with bmv0 does not work, so remove bmv0 from path
|
||||
# find bmvs around perimeter of bmf that could possibly be an entrance for shatter
|
||||
for bmv in bmf.verts:
|
||||
if not any(len(bme.link_faces)==0 for bme in bmv.link_edges):
|
||||
continue
|
||||
if find_exit(bmv): break
|
||||
else:
|
||||
# could not shatter current bmf
|
||||
continue
|
||||
# found a path to shatter bmf
|
||||
try:
|
||||
nbmf = bmf.split(path[0], path[-1], coords=[bmv.co for bmv in path[1:-1]])
|
||||
except Exception as e:
|
||||
print(f'shatter: Caught exception while trying to split {bmf} along {path}')
|
||||
print(e)
|
||||
continue
|
||||
for bmv_old in path[1:-1]:
|
||||
bmv_new,_ = min(((bmv,(bmv.co-bmv_old.co).length) for bmv in nbmf.verts), key=lambda d:d[1])
|
||||
if bmv_old.select: bmv_new.select = True
|
||||
bmv_new.merge(bmv_old)
|
||||
for bmv in bmf.verts + nbmf.verts:
|
||||
self.rftarget.clean_duplicate_bmedges(bmv)
|
||||
working.append(bmf) # check bmf again!
|
||||
working.append(nbmf) # check new bmf
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
class RFEdgeSequence:
|
||||
def __init__(self, sequence):
|
||||
if not sequence:
|
||||
self.verts = []
|
||||
self.edges = []
|
||||
self.loop = False
|
||||
return
|
||||
|
||||
seq = list(BMElemWrapper._unwrap(elem) for elem in sequence)
|
||||
|
||||
if type(seq[0]) is BMVert:
|
||||
self.verts = seq
|
||||
self.loop = (
|
||||
len(seq) > 1 and
|
||||
len(set(seq[0].link_edges) & set(seq[-1].link_edges)) != 0
|
||||
)
|
||||
self.edges = [next(iter(set(v0.link_edges) & set(v1.link_edges)))
|
||||
for v0, v1 in iter_pairs(seq, self.loop)]
|
||||
elif type(seq[0]) is BMEdge:
|
||||
self.edges = seq
|
||||
self.loop = len(seq) > 2 and len(
|
||||
set(seq[0].verts) & set(seq[-1].verts)) != 0
|
||||
if len(seq) == 1 and not self.loop:
|
||||
self.verts = seq[0].verts
|
||||
else:
|
||||
self.verts = [next(iter(set(e0.verts) & set(e1.verts)))
|
||||
for e0, e1 in iter_pairs(seq, self.loop)]
|
||||
else:
|
||||
assert False, 'unhandled type: %s' % str(type(seq[0]))
|
||||
|
||||
def __repr__(self):
|
||||
e = min(map(repr, self.edges)) if self.edges else None
|
||||
return f'<RFEdgeSequence: {len(self.verts)}, {self.loop}, {e}>'
|
||||
|
||||
def __len__(self):
|
||||
return len(self.edges)
|
||||
|
||||
def get_verts(self):
|
||||
return [RFVert(bmv) for bmv in self.verts]
|
||||
|
||||
def get_edges(self):
|
||||
return [RFEdge(bme) for bme in self.edges]
|
||||
|
||||
def is_loop(self):
|
||||
return self.loop
|
||||
|
||||
def iter_vert_pairs(self):
|
||||
return iter_pairs(self.get_verts(), self.loop)
|
||||
|
||||
def iter_edge_pairs(self):
|
||||
return iter_pairs(self.get_edges(), self.loop)
|
||||
@@ -0,0 +1,251 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from ..addon_common.common.blender import BlenderIcon, tag_redraw_all
|
||||
from ..addon_common.common.fsm import FSM
|
||||
from ..addon_common.common.functools import find_fns
|
||||
from ..addon_common.common.drawing import DrawCallbacks, Cursors
|
||||
from ..addon_common.common.boundvar import (
|
||||
BoundVar,
|
||||
BoundBool,
|
||||
BoundInt, BoundFloat,
|
||||
BoundString, BoundStringToBool,
|
||||
)
|
||||
from ..config.options import options, themes, visualization
|
||||
|
||||
|
||||
rftools = {}
|
||||
|
||||
def rftool_callback_decorator(event, fn):
|
||||
if not hasattr(fn, '_rftool_callback'):
|
||||
fn._rftool_callback = []
|
||||
fn._rftool_callback += [event]
|
||||
return fn
|
||||
|
||||
|
||||
|
||||
class RFTool:
|
||||
'''
|
||||
Assumes that direct subclass will have singleton instance (shared FSM among all instances of that subclass and any subclasses)
|
||||
'''
|
||||
registry = []
|
||||
|
||||
def __init_subclass__(cls, *args, **kwargs):
|
||||
global rftools
|
||||
|
||||
rftools[cls.__name__] = cls
|
||||
if not hasattr(cls, '_rftool_index'):
|
||||
# add cls to registry (might get updated later) and add FSM
|
||||
if False: print(f'RFTool: adding to registry at index {len(RFTool.registry)}: {cls} {cls.__name__} ')
|
||||
cls._rftool_index = len(RFTool.registry)
|
||||
RFTool.registry.append(cls)
|
||||
if not hasattr(cls, 'quick_shortcut'): cls.quick_shortcut = None
|
||||
if not hasattr(cls, 'ui_config'): cls.ui_config = None
|
||||
else:
|
||||
# update registry, but do not add new FSM
|
||||
if False: print(f'RFTool: updating registry at index {cls._rftool_index}: {cls} {cls.__name__}')
|
||||
RFTool.registry[cls._rftool_index] = cls
|
||||
pass
|
||||
super().__init_subclass__(*args, **kwargs)
|
||||
|
||||
|
||||
#####################################################
|
||||
# function decorators for different events
|
||||
|
||||
_events = {
|
||||
'init', # called when RF starts up
|
||||
'quickselect start', # called when quick select is used and tool should be started
|
||||
'quickswitch start', # called when quick switch to tool
|
||||
'ui setup', # called when RF is setting up UI
|
||||
'reset', # called when RF switches into tool or undo/redo
|
||||
'timer', # called every timer interval
|
||||
'target change', # called whenever rftarget has changed (selection or edited)
|
||||
'view change', # called whenever view has changed
|
||||
'mouse move', # called whenever mouse has moved
|
||||
'mouse stop', # called whenever mouse has stopped moving
|
||||
'new frame', # called each frame
|
||||
|
||||
# the following are filters, not events, so the decorated fns are immediately wrapped
|
||||
'once per frame', # only called once per frame
|
||||
'not while navigating', # delay calling until after navigating
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def on_init(fn): return rftool_callback_decorator('init', fn)
|
||||
@staticmethod
|
||||
def on_quickselect_start(fn): return rftool_callback_decorator('quickselect start', fn)
|
||||
@staticmethod
|
||||
def on_quickswitch_start(fn): return rftool_callback_decorator('quickswitch start', fn)
|
||||
@staticmethod
|
||||
def on_ui_setup(fn): return rftool_callback_decorator('ui setup', fn)
|
||||
@staticmethod
|
||||
def on_reset(fn): return rftool_callback_decorator('reset', fn)
|
||||
@staticmethod
|
||||
def on_timer(fn): return rftool_callback_decorator('timer', fn)
|
||||
@staticmethod
|
||||
def on_target_change(fn): return rftool_callback_decorator('target change', fn)
|
||||
@staticmethod
|
||||
def on_view_change(fn): return rftool_callback_decorator('view change', fn)
|
||||
@staticmethod
|
||||
def on_mouse_move(fn): return rftool_callback_decorator('mouse move', fn)
|
||||
@staticmethod
|
||||
def on_mouse_stop(fn): return rftool_callback_decorator('mouse stop', fn)
|
||||
@staticmethod
|
||||
def on_new_frame(fn): return rftool_callback_decorator('new frame', fn)
|
||||
@staticmethod
|
||||
def on_events(*events):
|
||||
assert not (unknown := set(events) - RFTool._events), f'Unhandled on_event {unknown}'
|
||||
def wrapper(fn):
|
||||
for event in events:
|
||||
rftool_callback_decorator(event, fn)
|
||||
return fn
|
||||
return wrapper
|
||||
|
||||
@staticmethod
|
||||
def once_per_frame(fn):
|
||||
name, count = fn.__name__, None
|
||||
@wraps(fn)
|
||||
def wrapped(self, *args, **kwargs):
|
||||
nonlocal name, count
|
||||
if count == RFTool._draw_count:
|
||||
if hasattr(self, '_callback_next_frame'):
|
||||
self._callback_next_frame.setdefault(name, lambda: fn(self, *args, **kwargs))
|
||||
tag_redraw_all('once per frame')
|
||||
else:
|
||||
count = RFTool._draw_count
|
||||
fn(self, *args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
@staticmethod
|
||||
def not_while_navigating(fn):
|
||||
name = fn.__name__
|
||||
@wraps(fn)
|
||||
def wrapped(self, *args, **kwargs):
|
||||
nonlocal name
|
||||
if RFTool.actions.is_navigating:
|
||||
if hasattr(self, '_callback_after_navigating'):
|
||||
self._callback_after_navigating.setdefault(name, lambda: fn(self, *args, **kwargs))
|
||||
else:
|
||||
fn(self, *args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
def _gather_callbacks(self):
|
||||
rftool_fns = find_fns(self, '_rftool_callback', full_search=True)
|
||||
self._callbacks = {
|
||||
mode: [fn for (modes, fn) in rftool_fns if mode in modes]
|
||||
for mode in self._events
|
||||
}
|
||||
|
||||
|
||||
def _callback(self, event, *args, **kwargs):
|
||||
ret = []
|
||||
for fn in self._callbacks.get(event, []):
|
||||
ret.append(fn(self, *args, **kwargs))
|
||||
return ret
|
||||
|
||||
def call_with_self_in_context(self, fn, *args, **kwargs):
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
|
||||
def __init__(self, rfcontext, start='main', reset_state=None):
|
||||
RFTool.rfcontext = rfcontext
|
||||
RFTool.drawing = rfcontext.drawing
|
||||
RFTool.actions = rfcontext.actions
|
||||
RFTool.document = rfcontext.document
|
||||
self.rfwidges = {}
|
||||
self.rfwidget = None
|
||||
self._fsm = FSM(self, start=start, reset_state=reset_state)
|
||||
self._draw = DrawCallbacks(self)
|
||||
self._gather_callbacks()
|
||||
self._callback('init')
|
||||
self._reset()
|
||||
|
||||
def _reset(self):
|
||||
self._callback_after_navigating = {}
|
||||
self._callback_next_frame = {}
|
||||
RFTool._draw_count = -1
|
||||
self._fsm.force_reset()
|
||||
self._callback('reset')
|
||||
self._update_all()
|
||||
|
||||
def _update_all(self):
|
||||
self._callback('timer')
|
||||
self._callback('target change')
|
||||
self._callback('view change')
|
||||
|
||||
def _fsm_update(self):
|
||||
if self.actions.mousemove: self._callback('mouse move')
|
||||
elif self.actions.mousemove_prev: self._callback('mouse stop')
|
||||
return self._fsm.update()
|
||||
|
||||
def _fsm_in_main(self):
|
||||
return self._fsm.state in {'main'}
|
||||
|
||||
@staticmethod
|
||||
def dirty_when_done(fn):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
ret = fn(*args, **kwargs)
|
||||
RFTool.rfcontext.dirty()
|
||||
return ret
|
||||
return wrapper
|
||||
|
||||
def dirty(self):
|
||||
RFTool.rfcontext.dirty()
|
||||
|
||||
def _new_frame(self):
|
||||
RFTool._draw_count = self.rfcontext._draw_count
|
||||
self._callback('new frame')
|
||||
|
||||
fns = list(self._callback_next_frame.values())
|
||||
self._callback_next_frame.clear()
|
||||
for fn in fns: fn()
|
||||
|
||||
def _done_navigating(self):
|
||||
fns = list(self._callback_after_navigating.values())
|
||||
self._callback_after_navigating.clear()
|
||||
for fn in fns: fn()
|
||||
|
||||
def _draw_pre3d(self): self._draw.pre3d()
|
||||
def _draw_post3d(self): self._draw.post3d()
|
||||
def _draw_post2d(self): self._draw.post2d()
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def icon_id(cls):
|
||||
return BlenderIcon.icon_id(cls.icon)
|
||||
|
||||
def clear_widget(self):
|
||||
self.set_widget(None)
|
||||
def set_widget(self, widget):
|
||||
self.rfwidget = self.rfwidgets[widget] if type(widget) is str else widget
|
||||
if self.rfwidget: self.rfwidget.set_cursor()
|
||||
else: Cursors.set('DEFAULT')
|
||||
|
||||
def handle_inactive_passthrough(self):
|
||||
for rfwidget in self.rfwidgets.values():
|
||||
if self.rfwidget == rfwidget: continue
|
||||
if rfwidget.inactive_passthrough():
|
||||
self.set_widget(rfwidget)
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,22 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
__all__ = ['contours']
|
||||
@@ -0,0 +1,757 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import os
|
||||
import math
|
||||
import time
|
||||
import random
|
||||
|
||||
from mathutils import Matrix
|
||||
|
||||
from ..rftool import RFTool
|
||||
from ..rfwidget import RFWidget
|
||||
|
||||
from ...addon_common.common import gpustate
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.debug import dprint
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.drawing import Drawing, DrawCallbacks
|
||||
from ...addon_common.common.maths import Point, Normal, Vec2D, Plane, Vec
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.timerhandler import CallGovernor, StopwatchHandler
|
||||
from ...addon_common.common.utils import iter_pairs
|
||||
from ...addon_common.common import blender_preferences as bprefs
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
|
||||
from ...config.options import options
|
||||
|
||||
from .contours_ops import Contours_Ops
|
||||
from .contours_props import Contours_Props
|
||||
from .contours_utils import (
|
||||
find_loops,
|
||||
find_strings,
|
||||
loop_plane, loop_radius,
|
||||
Contours_Loop,
|
||||
Contours_Utils,
|
||||
)
|
||||
|
||||
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
|
||||
from ..rfwidgets.rfwidget_linecut import RFWidget_LineCut_Factory
|
||||
|
||||
|
||||
class Contours(RFTool, Contours_Ops, Contours_Props, Contours_Utils):
|
||||
name = 'Contours'
|
||||
description = 'Retopologize cylindrical forms, like arms and legs'
|
||||
icon = 'contours-icon.png'
|
||||
help = 'contours.md'
|
||||
shortcut = 'contours tool'
|
||||
statusbar = '{{insert}} Insert contour\t{{increase count}} Increase segments\t{{decrease count}} Decrease segments\t{{fill}} Bridge'
|
||||
ui_config = 'contours_options.html'
|
||||
|
||||
RFWidget_Default = RFWidget_Default_Factory.create()
|
||||
RFWidget_Move = RFWidget_Default_Factory.create(cursor='HAND')
|
||||
RFWidget_LineCut = RFWidget_LineCut_Factory.create('Contours line cut')
|
||||
|
||||
@RFTool.on_init
|
||||
def init(self):
|
||||
self.rfwidgets = {
|
||||
'default': self.RFWidget_Default(self),
|
||||
'cut': self.RFWidget_LineCut(self),
|
||||
'hover': self.RFWidget_Move(self),
|
||||
}
|
||||
self.clear_widget()
|
||||
|
||||
@RFTool.on_reset
|
||||
def reset(self):
|
||||
self.show_cut = False
|
||||
self.show_arrows = False
|
||||
self.pts = []
|
||||
self.cut_pts = []
|
||||
self.connected = False
|
||||
self.cuts = []
|
||||
self.crawl_viz = [] # for debugging
|
||||
self.hovering_sel_edge = None
|
||||
self.ui_initial_count = None
|
||||
|
||||
@RFTool.on_target_change
|
||||
#@FSM.onlyinstate('main')
|
||||
def update_target(self):
|
||||
self.sel_edges = set(self.rfcontext.get_selected_edges())
|
||||
#sel_faces = self.rfcontext.get_selected_faces()
|
||||
|
||||
# disable initial count input box if anything is selected
|
||||
if not self.ui_initial_count:
|
||||
self.ui_initial_count = self.document.body.getElementById('contours-initial-count')
|
||||
if self.ui_initial_count:
|
||||
self.ui_initial_count.disabled = bool(self.sel_edges)
|
||||
|
||||
# find verts along selected loops and strings
|
||||
sel_loops = find_loops(self.sel_edges)
|
||||
sel_strings = find_strings(self.sel_edges)
|
||||
|
||||
# filter out any loops or strings that are in the middle of a selected patch
|
||||
def in_middle(bmvs, is_loop):
|
||||
return any(len(bmv0.shared_edge(bmv1).link_faces) > 1 for bmv0,bmv1 in iter_pairs(bmvs, is_loop))
|
||||
sel_loops = [loop for loop in sel_loops if not in_middle(loop, True)]
|
||||
sel_strings = [string for string in sel_strings if not in_middle(string, False)]
|
||||
|
||||
# filter out long loops that wrap around patches, sharing edges with other strings
|
||||
bmes = {bmv0.shared_edge(bmv1) for string in sel_strings for bmv0,bmv1 in iter_pairs(string,False)}
|
||||
sel_loops = [loop for loop in sel_loops if not any(bmv0.shared_edge(bmv1) in bmes for bmv0,bmv1 in iter_pairs(loop,True))]
|
||||
|
||||
mirror_mod = self.rfcontext.rftarget.mirror_mod
|
||||
symmetry_threshold = mirror_mod.symmetry_threshold
|
||||
def get_string_length(string):
|
||||
nonlocal mirror_mod, symmetry_threshold
|
||||
c = len(string)
|
||||
if c == 0: return 0
|
||||
touches_mirror = False
|
||||
(x0,y0,z0),(x1,y1,z1) = string[0].co,string[-1].co
|
||||
if mirror_mod.x:
|
||||
if abs(x0) < symmetry_threshold or abs(x1) < symmetry_threshold:
|
||||
c = (c - 1) * 2
|
||||
touches_mirror = True
|
||||
if mirror_mod.y:
|
||||
if abs(y0) < symmetry_threshold or abs(y1) < symmetry_threshold:
|
||||
c = (c - 1) * 2
|
||||
touches_mirror = True
|
||||
if mirror_mod.z:
|
||||
if abs(z0) < symmetry_threshold or abs(z1) < symmetry_threshold:
|
||||
c = (c - 1) * 2
|
||||
touches_mirror = True
|
||||
if not touches_mirror: c -= 1
|
||||
return c
|
||||
|
||||
self.loops_data = [{
|
||||
'loop': loop,
|
||||
'plane': loop_plane(loop),
|
||||
'count': len(loop),
|
||||
'radius': loop_radius(loop),
|
||||
'cl': Contours_Loop(loop, True),
|
||||
} for loop in sel_loops]
|
||||
self.strings_data = [{
|
||||
'string': string,
|
||||
'plane': loop_plane(string),
|
||||
'count': get_string_length(string),
|
||||
'cl': Contours_Loop(string, False),
|
||||
} for string in sel_strings]
|
||||
self.sel_loops = [Contours_Loop(loop, True) for loop in sel_loops]
|
||||
|
||||
self._var_cut_count.disabled = True
|
||||
if len(self.loops_data) == 1 and len(self.strings_data) == 0:
|
||||
self._var_cut_count_value = self.loops_data[0]['count']
|
||||
self._var_cut_count.disabled = any(len(e.link_edges)!=2 for e in self.loops_data[0]['loop'])
|
||||
if len(self.strings_data) == 1 and len(self.loops_data) == 0:
|
||||
self._var_cut_count_value = self.strings_data[0]['count']
|
||||
self._var_cut_count.disabled = False
|
||||
|
||||
@FSM.on_state('main')
|
||||
def main(self):
|
||||
if not self.actions.using('action', ignoredrag=True):
|
||||
# only update while not pressing action, because action includes drag, and
|
||||
# the artist might move mouse off selected edge before drag kicks in!
|
||||
self.hovering_sel_edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['action dist'], selected_only=True)
|
||||
|
||||
if self.actions.using_onlymods('insert'):
|
||||
self.set_widget('cut')
|
||||
elif self.hovering_sel_edge:
|
||||
self.set_widget('hover')
|
||||
else:
|
||||
self.set_widget('default')
|
||||
|
||||
if self.handle_inactive_passthrough(): return
|
||||
|
||||
if self.actions.pressed('grab'):
|
||||
''' grab for translations '''
|
||||
self.move_done_pressed = 'confirm'
|
||||
self.move_done_released = None
|
||||
return 'grab'
|
||||
|
||||
if self.hovering_sel_edge:
|
||||
if self.actions.pressed('action'):
|
||||
# return self.action_setup()
|
||||
self.move_done_pressed = None
|
||||
self.move_done_released = 'action'
|
||||
return 'grab'
|
||||
|
||||
if self.rfcontext.actions.pressed('rotate plane'):
|
||||
''' rotation of loops (NOT strips) about plane normal '''
|
||||
return 'rotate plane'
|
||||
|
||||
if self.rfcontext.actions.pressed('rotate screen'):
|
||||
''' screen-space rotation of loops about plane origin '''
|
||||
return 'rotate screen'
|
||||
|
||||
if self.rfcontext.actions.pressed('fill'):
|
||||
self.fill()
|
||||
return
|
||||
|
||||
if self.rfcontext.actions.pressed({'increase count', 'decrease count'}, unpress=False):
|
||||
delta = 1 if self.rfcontext.actions.pressed('increase count') else -1
|
||||
self.rfcontext.undo_push('change segment count', repeatable=True)
|
||||
self.change_count(delta=delta)
|
||||
return
|
||||
|
||||
if self.actions.pressed({'select paint', 'select paint add'}, unpress=False):
|
||||
sel_only = self.actions.pressed('select paint')
|
||||
return self.rfcontext.setup_smart_selection_painting(
|
||||
{'edge'},
|
||||
use_select_tool=True,
|
||||
selecting=not sel_only,
|
||||
deselect_all=sel_only,
|
||||
# fn_filter_bmelem=self.filter_edge_selection,
|
||||
kwargs_select={'supparts': False},
|
||||
kwargs_deselect={'subparts': False},
|
||||
)
|
||||
|
||||
if self.actions.pressed({'select path add'}):
|
||||
return self.rfcontext.select_path(
|
||||
{'edge'},
|
||||
fn_filter_bmelem=self.filter_edge_selection,
|
||||
kwargs_select={'supparts': False},
|
||||
)
|
||||
|
||||
if self.actions.pressed({'select single', 'select single add'}, unpress=False):
|
||||
self.sel_only = self.actions.pressed('select single')
|
||||
self.actions.unpress()
|
||||
self.select_single()
|
||||
return
|
||||
|
||||
if self.rfcontext.actions.pressed({'select smart', 'select smart add'}, unpress=False):
|
||||
self.select_single.cancel()
|
||||
sel_only = self.rfcontext.actions.pressed('select smart')
|
||||
self.rfcontext.actions.unpress()
|
||||
|
||||
self.rfcontext.undo_push('select smart')
|
||||
edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=10)
|
||||
if not edge:
|
||||
if sel_only: self.rfcontext.deselect_all()
|
||||
return
|
||||
self.rfcontext.select_edge_loop(edge, only=sel_only, supparts=False)
|
||||
return
|
||||
|
||||
@StopwatchHandler.delayed(time_delay=0.1)
|
||||
def select_single(self):
|
||||
bme,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['select dist'])
|
||||
if not bme and not self.sel_only: return
|
||||
|
||||
self.rfcontext.undo_push('select')
|
||||
if self.sel_only: self.rfcontext.deselect_all()
|
||||
if not bme: return
|
||||
|
||||
if bme.select: self.rfcontext.deselect(bme, subparts=False)
|
||||
else: self.rfcontext.select(bme, supparts=False, only=self.sel_only)
|
||||
self.rfcontext.dirty(selectionOnly=True)
|
||||
|
||||
@FSM.on_state('rotate plane', 'can enter')
|
||||
def rotateplane_can_enter(self):
|
||||
sel_edges = self.rfcontext.get_selected_edges()
|
||||
sel_loops = find_loops(sel_edges)
|
||||
if not sel_loops:
|
||||
if self.strings_data:
|
||||
self.rfcontext.alert_user('Can only plane-rotate complete loops that do not cross the symmetry plane')
|
||||
else:
|
||||
self.rfcontext.alert_user('Could not find valid loops to plane-rotate')
|
||||
return False
|
||||
|
||||
self.move_cloops = [Contours_Loop(loop, True) for loop in sel_loops]
|
||||
self.move_verts = [[bmv for bmv in cloop.verts] for cloop in self.move_cloops]
|
||||
self.move_pts = [[Point(pt) for pt in cloop.pts] for cloop in self.move_cloops]
|
||||
self.move_dists = [list(cloop.dists) for cloop in self.move_cloops]
|
||||
self.move_circumferences = [cloop.circumference for cloop in self.move_cloops]
|
||||
self.move_origins = [cloop.plane.o for cloop in self.move_cloops]
|
||||
self.move_proj_dists = [list(cloop.proj_dists) for cloop in self.move_cloops]
|
||||
|
||||
self.move_cuts = []
|
||||
for cloop in self.move_cloops:
|
||||
xy = self.rfcontext.Point_to_Point2D(cloop.plane.o)
|
||||
ray = self.rfcontext.Point2D_to_Ray(xy)
|
||||
crawl = self.rfcontext.plane_intersection_crawl(ray, cloop.plane, walk_to_plane=True)
|
||||
if not crawl:
|
||||
# dprint('could not crawl around sources for loop')
|
||||
pass
|
||||
self.move_cuts += [None]
|
||||
continue
|
||||
crawl_pts = [c for _,c,_ in crawl]
|
||||
connected = cloop.connected # XXX why was `crawl[0][0] is not None` here?
|
||||
crawl_pts,connected = self.rfcontext.clip_pointloop(crawl_pts, connected)
|
||||
if not crawl_pts or connected != cloop.connected:
|
||||
# dprint('could not clip loop to symmetry')
|
||||
pass
|
||||
self.move_cuts += [None]
|
||||
continue
|
||||
cl_cut = Contours_Loop(crawl_pts, connected)
|
||||
cl_cut.align_to(cloop)
|
||||
self.move_cuts += [cl_cut]
|
||||
if not any(self.move_cuts):
|
||||
self.rfcontext.alert_user('Could not find valid loops to plane-rotate')
|
||||
# dprint('Found no loops to shift')
|
||||
pass
|
||||
return False
|
||||
|
||||
@FSM.on_state('rotate plane', 'enter')
|
||||
def rotateplane_enter(self):
|
||||
self.rot_axis = Vec((0,0,0))
|
||||
self.rot_origin = Point.average(cut.get_origin() for cut in self.move_cuts if cut)
|
||||
self.shift_about = self.rfcontext.Point_to_Point2D(self.rot_origin)
|
||||
for cut in self.move_cuts:
|
||||
if not cut: continue
|
||||
a = cut.get_normal()
|
||||
o = cut.get_origin()
|
||||
if self.rot_axis.dot(a) < 0: a = -a
|
||||
self.rot_axis += a
|
||||
self.rot_axis.normalize()
|
||||
p0 = next(iter(cut.get_origin() for cut in self.move_cuts if cut))
|
||||
p1 = p0 + self.rot_axis * 0.001
|
||||
self.rot_axis2D = (self.rfcontext.Point_to_Point2D(p1) - self.rfcontext.Point_to_Point2D(p0))
|
||||
self.rot_axis2D.normalize()
|
||||
self.rot_perp2D = Vec2D((self.rot_axis2D.y, -self.rot_axis2D.x))
|
||||
# print(self.rot_axis, self.rot_axis2D, self.rot_perp2D)
|
||||
|
||||
self.rfcontext.undo_push('rotate plane contours')
|
||||
|
||||
self.mousedown = self.rfcontext.actions.mouse
|
||||
|
||||
self._timer = self.actions.start_timer(120.0)
|
||||
self.rfcontext.split_target_visualization(verts=[v for vs in self.move_verts for v in vs])
|
||||
self.rfcontext.set_accel_defer(True)
|
||||
|
||||
@FSM.on_state('rotate plane')
|
||||
# @profiler.function
|
||||
def rotateplane_main(self):
|
||||
if self.rfcontext.actions.pressed('confirm'):
|
||||
return 'main'
|
||||
if self.rfcontext.actions.pressed('cancel'):
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
|
||||
if self.rfcontext.actions.pressed('rotate screen'):
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'rotate screen'
|
||||
|
||||
if not self.actions.mousemove_stop: return
|
||||
# # only update cut on timer events and when mouse has moved
|
||||
# if not self.rfcontext.actions.timer: return
|
||||
|
||||
delta = Vec2D(self.rfcontext.actions.mouse - self.mousedown)
|
||||
shift_offset = self.rfcontext.drawing.unscale(self.rot_perp2D.dot(delta)) / 1000
|
||||
up_dir = self.rfcontext.Vec_up()
|
||||
|
||||
raycast,project = self.rfcontext.raycast_sources_Point2D,self.rfcontext.Point_to_Point2D
|
||||
for i_cloop in range(len(self.move_cloops)):
|
||||
cloop = self.move_cloops[i_cloop]
|
||||
cl_cut = self.move_cuts[i_cloop]
|
||||
if not cl_cut: continue
|
||||
shift_dir = 1 if cl_cut.get_normal().dot(self.rot_axis) > 0 else -1
|
||||
|
||||
verts = self.move_verts[i_cloop]
|
||||
dists = self.move_dists[i_cloop]
|
||||
proj_dists = self.move_proj_dists[i_cloop]
|
||||
circumference = self.move_circumferences[i_cloop]
|
||||
|
||||
lc = cl_cut.circumference
|
||||
shft = (cl_cut.offset + shift_offset * shift_dir * lc) % lc
|
||||
ndists = [shft] + [0.999 * lc * (d/circumference) for d in dists]
|
||||
i,dist = 0,ndists[0]
|
||||
l = len(ndists)-1 if cloop.connected else len(ndists)
|
||||
for c0,c1 in cl_cut.iter_pts(repeat=True):
|
||||
d = (c1-c0).length
|
||||
while dist - d <= 0:
|
||||
# create new vert between c0 and c1
|
||||
p = c0 + (c1 - c0) * (dist / d) + (cloop.plane.n * proj_dists[i])
|
||||
p,n,_,_ = self.rfcontext.nearest_sources_Point(p)
|
||||
verts[i].co = p
|
||||
verts[i].normal = n
|
||||
i += 1
|
||||
if i == l: break
|
||||
dist += ndists[i]
|
||||
dist -= d
|
||||
if i == l: break
|
||||
|
||||
self.rfcontext.update_verts_faces(verts)
|
||||
self.rfcontext.dirty()
|
||||
|
||||
@FSM.on_state('rotate plane', 'exit')
|
||||
def rotateplane_exit(self):
|
||||
self._timer.done()
|
||||
self.rfcontext.clear_split_target_visualization()
|
||||
self.rfcontext.set_accel_defer(False)
|
||||
tag_redraw_all('Contours finish rotate')
|
||||
|
||||
|
||||
def action_setup(self):
|
||||
sel_edges = self.rfcontext.get_selected_edges()
|
||||
sel_loops = find_loops(sel_edges)
|
||||
sel_strings = find_strings(sel_edges, min_length=2)
|
||||
if not sel_loops or sel_strings: return
|
||||
|
||||
# prefer to move loops over strings
|
||||
if sel_loops: self.move_cloops = [Contours_Loop(loop, True) for loop in sel_loops]
|
||||
else: self.move_cloops = [Contours_Loop(string, False) for string in sel_strings]
|
||||
self.move_verts = [[bmv for bmv in cloop.verts] for cloop in self.move_cloops]
|
||||
self.move_pts = [[Point(pt) for pt in cloop.pts] for cloop in self.move_cloops]
|
||||
self.move_dists = [list(cloop.dists) for cloop in self.move_cloops]
|
||||
self.move_circumferences = [cloop.circumference for cloop in self.move_cloops]
|
||||
self.move_origins = [cloop.plane.o for cloop in self.move_cloops]
|
||||
self.move_orig_origins = [Point(p) for p in self.move_origins]
|
||||
self.move_proj_dists = [list(cloop.proj_dists) for cloop in self.move_cloops]
|
||||
|
||||
#self.grab_along = self.rfcontext.Point_to_Point2D(sum(self.move_origins, Vec((0,0,0))) / len(self.move_origins))
|
||||
#self.rotate_start = math.atan2(self.rotate_about.y - self.mousedown.y, self.rotate_about.x - self.mousedown.x)
|
||||
|
||||
self.mousedown = self.actions.mouse
|
||||
self.move_prevmouse = None
|
||||
|
||||
return self.rfcontext.setup_action()
|
||||
|
||||
def action_callback(self, val):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@FSM.on_state('grab', 'can enter')
|
||||
def grab_can_enter(self):
|
||||
sel_edges = self.rfcontext.get_selected_edges()
|
||||
sel_loops = find_loops(sel_edges)
|
||||
sel_strings = find_strings(sel_edges, min_length=2)
|
||||
return bool(sel_loops or sel_strings)
|
||||
|
||||
@FSM.on_state('grab', 'enter')
|
||||
def grab_enter(self):
|
||||
sel_edges = self.rfcontext.get_selected_edges()
|
||||
sel_loops = find_loops(sel_edges)
|
||||
sel_strings = find_strings(sel_edges, min_length=2)
|
||||
|
||||
# prefer to move loops over strings
|
||||
if sel_loops: self.move_cloops = [Contours_Loop(loop, True) for loop in sel_loops]
|
||||
else: self.move_cloops = [Contours_Loop(string, False) for string in sel_strings]
|
||||
self.move_verts = [[bmv for bmv in cloop.verts] for cloop in self.move_cloops]
|
||||
self.move_pts = [[Point(pt) for pt in cloop.pts] for cloop in self.move_cloops]
|
||||
self.move_dists = [list(cloop.dists) for cloop in self.move_cloops]
|
||||
self.move_circumferences = [cloop.circumference for cloop in self.move_cloops]
|
||||
self.move_origins = [cloop.plane.o for cloop in self.move_cloops]
|
||||
self.move_orig_origins = [Point(p) for p in self.move_origins]
|
||||
self.move_proj_dists = [list(cloop.proj_dists) for cloop in self.move_cloops]
|
||||
|
||||
self.rfcontext.undo_push('grab contours')
|
||||
|
||||
#self.grab_along = self.rfcontext.Point_to_Point2D(sum(self.move_origins, Vec((0,0,0))) / len(self.move_origins))
|
||||
#self.rotate_start = math.atan2(self.rotate_about.y - self.mousedown.y, self.rotate_about.x - self.mousedown.x)
|
||||
|
||||
self.grab_opts = {
|
||||
'mousedown': self.actions.mouse,
|
||||
'timer': self.actions.start_timer(120.0),
|
||||
}
|
||||
self.rfcontext.split_target_visualization(verts=[v for vs in self.move_verts for v in vs])
|
||||
self.rfcontext.set_accel_defer(True)
|
||||
|
||||
|
||||
@FSM.on_state('grab')
|
||||
# @profiler.function
|
||||
def grab(self):
|
||||
opts = self.grab_opts
|
||||
|
||||
if self.rfcontext.actions.pressed(self.move_done_pressed):
|
||||
return 'main'
|
||||
if self.rfcontext.actions.released(self.move_done_released, ignoredrag=True):
|
||||
return 'main'
|
||||
if self.rfcontext.actions.pressed('cancel'):
|
||||
self.rfcontext.actions.unuse(self.move_done_released, ignoremods=True, ignoremulti=True)
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
|
||||
if not self.actions.mousemove_stop: return
|
||||
# # only update cut on timer events and when mouse has moved
|
||||
# if not self.rfcontext.actions.timer: return
|
||||
|
||||
delta = Vec2D(self.actions.mouse - opts['mousedown'])
|
||||
# print(f'contours.grab: {delta}')
|
||||
# self.crawl_viz = []
|
||||
|
||||
raycast,project = self.rfcontext.raycast_sources_Point2D,self.rfcontext.Point_to_Point2D
|
||||
for i_cloop in range(len(self.move_cloops)):
|
||||
cloop = self.move_cloops[i_cloop]
|
||||
verts = self.move_verts[i_cloop]
|
||||
pts = self.move_pts[i_cloop]
|
||||
dists = self.move_dists[i_cloop]
|
||||
origin = self.move_origins[i_cloop]
|
||||
proj_dists = self.move_proj_dists[i_cloop]
|
||||
circumference = self.move_circumferences[i_cloop]
|
||||
|
||||
depth = self.rfcontext.Point_to_depth(origin)
|
||||
if depth is None: continue
|
||||
origin2D_new = self.rfcontext.Point_to_Point2D(origin) + delta
|
||||
origin_new = self.rfcontext.Point2D_to_Point(origin2D_new, depth)
|
||||
plane_new = Plane(origin_new, cloop.plane.n)
|
||||
ray_new = self.rfcontext.Point2D_to_Ray(origin2D_new)
|
||||
crawl = self.rfcontext.plane_intersection_crawl(ray_new, plane_new, walk_to_plane=True)
|
||||
if not crawl: continue
|
||||
crawl_pts = [c for _,c,_ in crawl]
|
||||
# self.crawl_viz += [crawl_pts]
|
||||
connected = crawl[0][0] is not None
|
||||
crawl_pts,nconnected = self.rfcontext.clip_pointloop(crawl_pts, connected)
|
||||
connected = nconnected
|
||||
if not crawl_pts or connected != cloop.connected: continue
|
||||
cl_cut = Contours_Loop(crawl_pts, connected)
|
||||
|
||||
cl_cut.align_to(cloop)
|
||||
lc = cl_cut.circumference
|
||||
ndists = [cl_cut.offset] + [0.999 * lc * (d/circumference) for d in dists]
|
||||
i,dist = 0,ndists[0]
|
||||
l = len(ndists)-1 if cloop.connected else len(ndists)
|
||||
for c0,c1 in cl_cut.iter_pts(repeat=True):
|
||||
d = max(0.000001, (c1-c0).length)
|
||||
while dist - d <= 0:
|
||||
# create new vert between c0 and c1
|
||||
p = c0 + (c1 - c0) * (dist / d) + (cloop.plane.n * proj_dists[i])
|
||||
p,_,_,_ = self.rfcontext.nearest_sources_Point(p)
|
||||
verts[i].co = p
|
||||
i += 1
|
||||
if i == l: break
|
||||
dist += ndists[i]
|
||||
dist -= d
|
||||
if i == l: break
|
||||
|
||||
self.rfcontext.update_verts_faces(verts)
|
||||
self.rfcontext.dirty()
|
||||
|
||||
@FSM.on_state('grab', 'exit')
|
||||
def grab_exit(self):
|
||||
self.grab_opts['timer'].done()
|
||||
self.rfcontext.set_accel_defer(False)
|
||||
self.rfcontext.clear_split_target_visualization()
|
||||
tag_redraw_all('Contours finish grab')
|
||||
|
||||
|
||||
@FSM.on_state('rotate screen', 'can enter')
|
||||
def rotatescreen_can_enter(self):
|
||||
sel_edges = self.rfcontext.get_selected_edges()
|
||||
sel_loops = find_loops(sel_edges)
|
||||
sel_strings = find_strings(sel_edges, min_length=2)
|
||||
return sel_loops or sel_strings
|
||||
|
||||
@FSM.on_state('rotate screen', 'enter')
|
||||
def rotatescreen_enter(self):
|
||||
sel_edges = self.rfcontext.get_selected_edges()
|
||||
sel_loops = find_loops(sel_edges)
|
||||
sel_strings = find_strings(sel_edges, min_length=2)
|
||||
|
||||
# prefer to move loops over strings
|
||||
if sel_loops: self.move_cloops = [Contours_Loop(loop, True) for loop in sel_loops]
|
||||
else: self.move_cloops = [Contours_Loop(string, False) for string in sel_strings]
|
||||
self.move_verts = [[bmv for bmv in cloop.verts] for cloop in self.move_cloops]
|
||||
self.move_pts = [[Point(pt) for pt in cloop.pts] for cloop in self.move_cloops]
|
||||
self.move_dists = [list(cloop.dists) for cloop in self.move_cloops]
|
||||
self.move_circumferences = [cloop.circumference for cloop in self.move_cloops]
|
||||
self.move_origins = [cloop.plane.o for cloop in self.move_cloops]
|
||||
self.move_proj_dists = [list(cloop.proj_dists) for cloop in self.move_cloops]
|
||||
|
||||
self.rfcontext.undo_push('rotate screen contours')
|
||||
|
||||
self.mousedown = self.rfcontext.actions.mouse
|
||||
|
||||
self.rotate_about = self.rfcontext.Point_to_Point2D(sum(self.move_origins, Vec((0,0,0))) / len(self.move_origins))
|
||||
self.rotate_start = math.atan2(self.rotate_about.y - self.mousedown.y, self.rotate_about.x - self.mousedown.x)
|
||||
|
||||
self._timer = self.actions.start_timer(120.0)
|
||||
self.rfcontext.split_target_visualization(verts=[v for vs in self.move_verts for v in vs])
|
||||
self.rfcontext.set_accel_defer(True)
|
||||
|
||||
@FSM.on_state('rotate screen')
|
||||
# @profiler.function
|
||||
def rotatescreen_main(self):
|
||||
if self.rfcontext.actions.pressed('confirm'):
|
||||
return 'main'
|
||||
if self.rfcontext.actions.pressed('cancel'):
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
|
||||
if self.rfcontext.actions.pressed('rotate plane'):
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'rotate plane'
|
||||
|
||||
if not self.actions.mousemove_stop: return
|
||||
# # only update cut on timer events and when mouse has moved
|
||||
# if not self.rfcontext.actions.timer: return
|
||||
|
||||
delta = Vec2D(self.rfcontext.actions.mouse - self.rotate_about)
|
||||
rotate = (math.atan2(delta.y, delta.x) - self.rotate_start + math.pi) % (math.pi * 2)
|
||||
|
||||
raycast,project = self.rfcontext.raycast_sources_Point2D,self.rfcontext.Point_to_Point2D
|
||||
for i_cloop in range(len(self.move_cloops)):
|
||||
cloop = self.move_cloops[i_cloop]
|
||||
verts = self.move_verts[i_cloop]
|
||||
pts = self.move_pts[i_cloop]
|
||||
dists = self.move_dists[i_cloop]
|
||||
origin = self.move_origins[i_cloop]
|
||||
proj_dists = self.move_proj_dists[i_cloop]
|
||||
circumference = self.move_circumferences[i_cloop]
|
||||
|
||||
origin2D = self.rfcontext.Point_to_Point2D(origin)
|
||||
ray = self.rfcontext.Point_to_Ray(origin)
|
||||
rmat = Matrix.Rotation(rotate, 4, -ray.d)
|
||||
normal = rmat @ cloop.plane.n
|
||||
plane = Plane(cloop.plane.o, normal)
|
||||
ray = self.rfcontext.Point2D_to_Ray(origin2D)
|
||||
crawl = self.rfcontext.plane_intersection_crawl(ray, plane, walk_to_plane=True)
|
||||
if not crawl: continue
|
||||
crawl_pts = [c for _,c,_ in crawl]
|
||||
connected = crawl[0][0] is not None
|
||||
crawl_pts,connected = self.rfcontext.clip_pointloop(crawl_pts, connected)
|
||||
if not crawl_pts or connected != cloop.connected: continue
|
||||
cl_cut = Contours_Loop(crawl_pts, connected)
|
||||
|
||||
cl_cut.align_to(cloop)
|
||||
lc = cl_cut.circumference
|
||||
ndists = [cl_cut.offset] + [0.999 * lc * (d/circumference) for d in dists]
|
||||
i,dist = 0,ndists[0]
|
||||
l = len(ndists)-1 if cloop.connected else len(ndists)
|
||||
for c0,c1 in cl_cut.iter_pts(repeat=True):
|
||||
d = (c1-c0).length
|
||||
d = max(0.00000001, d)
|
||||
while dist - d <= 0:
|
||||
# create new vert between c0 and c1
|
||||
p = c0 + (c1 - c0) * (dist / d) + (cloop.plane.n * proj_dists[i])
|
||||
p,_,_,_ = self.rfcontext.nearest_sources_Point(p)
|
||||
verts[i].co = p
|
||||
i += 1
|
||||
if i == l: break
|
||||
dist += ndists[i]
|
||||
dist -= d
|
||||
if i == l: break
|
||||
|
||||
self.rfcontext.update_verts_faces(verts)
|
||||
self.rfcontext.dirty()
|
||||
|
||||
@FSM.on_state('rotate screen', 'exit')
|
||||
def rotatescreen_exit(self):
|
||||
self._timer.done()
|
||||
self.rfcontext.clear_split_target_visualization()
|
||||
self.rfcontext.set_accel_defer(False)
|
||||
tag_redraw_all('Contours finish rotate')
|
||||
|
||||
|
||||
@RFWidget.on_action('Contours line cut')
|
||||
def new_line(self):
|
||||
xy0,xy1 = self.rfwidgets['cut'].line2D
|
||||
if not xy0 or not xy1: return
|
||||
if (xy1-xy0).length < 0.001: return
|
||||
xy01 = xy0 + (xy1-xy0) / 2
|
||||
plane = self.rfcontext.Point2D_to_Plane(xy0, xy1)
|
||||
ray = self.rfcontext.Point2D_to_Ray(xy01)
|
||||
self.new_cut(ray, plane, walk_to_plane=False, check_hit=xy01)
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate('rotate screen')
|
||||
def draw_post2d_rotate_screenspace(self):
|
||||
gpustate.blend('ALPHA')
|
||||
Globals.drawing.draw2D_line(
|
||||
self.rotate_about,
|
||||
self.rfcontext.actions.mouse,
|
||||
(1.0, 1.0, 0.1, 1.0), color1=(1.0, 1.0, 0.1, 0.0),
|
||||
width=2, stipple=[2, 2]
|
||||
)
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate('rotate plane')
|
||||
def draw_post2d_rotate_plane(self):
|
||||
gpustate.blend('ALPHA')
|
||||
Globals.drawing.draw2D_line(
|
||||
self.shift_about + self.rot_axis2D * 1000,
|
||||
self.shift_about - self.rot_axis2D * 1000,
|
||||
(0.1, 1.0, 1.0, 1.0), color1=(0.1, 1.0, 1.0, 0.0),
|
||||
width=2, stipple=[2,2],
|
||||
)
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate('grab')
|
||||
def draw_post2d_grab(self):
|
||||
project = self.rfcontext.Point_to_Point2D
|
||||
intersect = self.rfcontext.raycast_sources_Point2D
|
||||
delta = Vec2D(self.actions.mouse - self.grab_opts['mousedown'])
|
||||
c0_good, c1_good = (1.0, 0.1, 1.0, 0.5), (1.0, 0.1, 1.0, 0.0)
|
||||
c0_bad, c1_bad = (1.0, 0.1, 0.1, 1.0), (1.0, 0.1, 0.1, 0.0)
|
||||
gpustate.blend('ALPHA')
|
||||
for o in self.move_origins:
|
||||
p0, p1 = project(o), project(o) + delta
|
||||
_p,_,_,_ = intersect(p1)
|
||||
Globals.drawing.draw2D_line(
|
||||
p0, p1,
|
||||
(c0_good if _p else c0_bad), color1=(c1_good if _p else c1_bad),
|
||||
width=2, stipple=[2,2],
|
||||
)
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
def draw_post2d(self):
|
||||
point_to_point2d = self.rfcontext.Point_to_Point2D
|
||||
is_visible = lambda p: self.rfcontext.is_visible(p, occlusion_test_override=True)
|
||||
up = self.rfcontext.Vec_up()
|
||||
size_to_size2D = self.rfcontext.size_to_size2D
|
||||
text_draw2D = self.rfcontext.drawing.text_draw2D
|
||||
self.rfcontext.drawing.set_font_size(12)
|
||||
|
||||
bmv_count = set()
|
||||
bmv_count_loops = {}
|
||||
bmv_count_strings = {}
|
||||
|
||||
for loop_data in self.loops_data:
|
||||
loop = loop_data['loop']
|
||||
radius = loop_data['radius']
|
||||
count = loop_data['count']
|
||||
plane = loop_data['plane']
|
||||
cl = loop_data['cl']
|
||||
|
||||
# draw segment count label
|
||||
loop = [vert for vert in loop if vert.is_valid]
|
||||
loop = [(vert, point_to_point2d(vert.co)) for vert in loop if is_visible(vert.co)]
|
||||
if loop:
|
||||
bmv = max(loop, key=lambda bmvp2d:bmvp2d[1].y)[0]
|
||||
if bmv not in bmv_count_loops: bmv_count_loops[bmv] = []
|
||||
bmv_count_loops[bmv].append(count)
|
||||
bmv_count.add(bmv)
|
||||
|
||||
for string_data in self.strings_data:
|
||||
string = string_data['string']
|
||||
count = string_data['count']
|
||||
plane = string_data['plane']
|
||||
|
||||
# draw segment count label
|
||||
string = [vert for vert in string if vert.is_valid]
|
||||
string = [(vert, point_to_point2d(vert.co)) for vert in string if is_visible(vert.co)]
|
||||
if string:
|
||||
bmv = max(string, key=lambda bmvp2d:bmvp2d[1].y)[0]
|
||||
if bmv not in bmv_count_strings: bmv_count_strings[bmv] = []
|
||||
bmv_count_strings[bmv].append(count)
|
||||
bmv_count.add(bmv)
|
||||
|
||||
for bmv in bmv_count:
|
||||
counts_loops = sorted(bmv_count_loops.get(bmv, []))
|
||||
counts_strings = sorted(bmv_count_strings.get(bmv, []))
|
||||
s_loops = ','.join(map(str, counts_loops))
|
||||
s_strings = ','.join(map(str, counts_strings))
|
||||
xy = point_to_point2d(bmv.co)
|
||||
xy.y += 10
|
||||
if s_loops:
|
||||
text_draw2D('O: ' + s_loops, xy, color=(1,1,0,1), dropshadow=(0,0,0,0.5))
|
||||
xy.y += 10
|
||||
if s_strings:
|
||||
text_draw2D('C: ' + s_strings, xy, color=(0,1,1,1), dropshadow=(0,0,0,0.5))
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import os
|
||||
import math
|
||||
from itertools import chain
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix
|
||||
|
||||
from ..rftool import RFTool
|
||||
|
||||
from ...addon_common.common.boundvar import BoundBool
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.utils import max_index, iter_pairs
|
||||
from ...addon_common.common.maths import Point,Point2D,Vec2D,Vec,Plane
|
||||
from ...config.options import options
|
||||
|
||||
from .contours_utils import (
|
||||
Contours_Loop,
|
||||
find_loops, find_strings, find_parallel_loops,
|
||||
loop_plane, loop_length, string_length,
|
||||
edges_between_loops,
|
||||
)
|
||||
|
||||
|
||||
class Contours_Ops:
|
||||
@RFTool.dirty_when_done
|
||||
def new_cut(self, ray, plane, count=None, walk_to_plane=True, check_hit=None, perform_nonmanifold_check=None):
|
||||
self.pts = []
|
||||
self.cut_pts = []
|
||||
self.cuts = []
|
||||
self.connected = False
|
||||
|
||||
crawl = self.rfcontext.plane_intersection_crawl(ray, plane, walk_to_plane=walk_to_plane)
|
||||
if not crawl: return
|
||||
|
||||
# get crawl data (over source)
|
||||
pts = [c for (f0,c,f1) in crawl]
|
||||
connected_preclip = crawl[0][0] is not None
|
||||
center = Point.average(pts)
|
||||
pts,connected = self.rfcontext.clip_pointloop(pts, connected_preclip)
|
||||
if not pts: return
|
||||
|
||||
self.rfcontext.undo_push('cut')
|
||||
|
||||
cl_cut = Contours_Loop(pts, connected)
|
||||
self.cuts = [cl_cut]
|
||||
self.cut_pts = pts
|
||||
self.connected = connected
|
||||
sel_edges = self.rfcontext.get_selected_edges()
|
||||
|
||||
if check_hit:
|
||||
# if ray hits target, include the loops, too!
|
||||
visible_faces = self.rfcontext.visible_faces()
|
||||
hit_face,_ = self.rfcontext.nearest2D_face(point=check_hit, faces=visible_faces)
|
||||
if hit_face and hit_face.is_quad():
|
||||
# considering loops only at the moment
|
||||
edges = hit_face.edges
|
||||
eseqs = [self.rfcontext.get_quadwalk_edgesequence(edge) for edge in edges]
|
||||
eloops = [eseq.get_edges() if len(eseq) else None for eseq in eseqs]
|
||||
cloops = [Contours_Loop(eseq.get_verts(), eseq.is_loop()) if eseq else None for eseq in eseqs]
|
||||
|
||||
# use loop that is most parallel to cut
|
||||
norm = cl_cut.plane.n
|
||||
idx0 = max_index([abs(norm.dot(cloop.plane.n)) if cloop else -1 for cloop in cloops])
|
||||
idx1 = (idx0 + 2) % 4
|
||||
sel_edges |= set(eloops[idx0]) | set(eloops[idx1])
|
||||
|
||||
sel_loop_pos,sel_loop_neg = None,None
|
||||
sel_string_pos,sel_string_neg = None,None
|
||||
|
||||
if connected:
|
||||
# find two closest selected loops, one on each side
|
||||
sel_loops = find_loops(sel_edges)
|
||||
# find loops running parallel to selection
|
||||
par_loops = [ploop for loop in sel_loops for ploop in find_parallel_loops(loop)]
|
||||
|
||||
sel_loop_planes = [(loop, loop_plane(loop)) for loop in sel_loops]
|
||||
par_loop_planes = [(loop, loop_plane(loop)) for loop in par_loops]
|
||||
|
||||
def get_closest(loop_planes, positive):
|
||||
nonlocal center, plane
|
||||
mult = 1 if positive else -1
|
||||
loops = sorted([
|
||||
# loop, distance to loop, segment count of loop, loop circumference
|
||||
#(loop, plane.distance_to(p.o), len(loop), loop_length(loop))
|
||||
(loop, (loop_plane.o - center).length, len(loop), loop_length(loop))
|
||||
for loop,loop_plane in loop_planes if plane.side(loop_plane.o)*mult > 0
|
||||
], key=lambda data:data[1])
|
||||
return next(iter(loops), None)
|
||||
# (loop, plane.distance_to(p.o), len(loop), loop_length(loop))
|
||||
# for loop,p in zip(sel_loops, sel_loop_planes) if plane.side(p.o) > 0
|
||||
|
||||
sel_loop_pos = get_closest(sel_loop_planes, True)
|
||||
sel_loop_neg = get_closest(sel_loop_planes, False)
|
||||
par_loop_pos = get_closest(par_loop_planes, True)
|
||||
par_loop_neg = get_closest(par_loop_planes, False)
|
||||
|
||||
# if we've got only one selected loop, see if any parallel loops are closer
|
||||
if sel_loop_pos and par_loop_pos:
|
||||
if par_loop_pos[1] < sel_loop_pos[1]:
|
||||
sel_loop_pos = par_loop_pos
|
||||
if sel_loop_neg and par_loop_neg:
|
||||
if par_loop_neg[1] < sel_loop_neg[1]:
|
||||
sel_loop_neg = par_loop_neg
|
||||
|
||||
if sel_loop_pos and sel_loop_neg:
|
||||
if sel_loop_pos[2] != sel_loop_neg[2]:
|
||||
# selected loops do not have same count of vertices
|
||||
# choosing the closer loop
|
||||
if sel_loop_pos[1] < sel_loop_neg[1]:
|
||||
sel_loop_neg = None
|
||||
else:
|
||||
sel_loop_pos = None
|
||||
else:
|
||||
# find two closest selected strings, one on each side
|
||||
sel_strings = find_strings(sel_edges)
|
||||
parallel_strings = [pstring for string in sel_strings for pstring in find_parallel_loops(string, False)]
|
||||
sel_strings += parallel_strings
|
||||
|
||||
sel_string_planes = [loop_plane(string) for string in sel_strings]
|
||||
sel_strings_pos = sorted([
|
||||
(string, plane.distance_to(p.o), len(string), string_length(string))
|
||||
for string,p in zip(sel_strings, sel_string_planes) if plane.side(p.o) > 0
|
||||
], key=lambda data:data[1])
|
||||
sel_strings_neg = sorted([
|
||||
(string, plane.distance_to(p.o), len(string), string_length(string))
|
||||
for string,p in zip(sel_strings, sel_string_planes) if plane.side(p.o) < 0
|
||||
], key=lambda data:data[1])
|
||||
sel_string_pos = next(iter(sel_strings_pos), None)
|
||||
sel_string_neg = next(iter(sel_strings_neg), None)
|
||||
if sel_string_pos and sel_string_neg:
|
||||
if sel_string_pos[2] != sel_string_neg[2]:
|
||||
# selected strings do not have same count of vertices
|
||||
# choosing the closer string
|
||||
if sel_string_pos[1] < sel_string_neg[1]:
|
||||
sel_string_neg = None
|
||||
else:
|
||||
sel_string_pos = None
|
||||
|
||||
if not count:
|
||||
count = self._var_init_count.value
|
||||
if connected != connected_preclip:
|
||||
count = int(math.ceil(count / 2)) + 1
|
||||
#count = count or self.get_count()
|
||||
count = sel_loop_pos[2] if sel_loop_pos else sel_loop_neg[2] if sel_loop_neg else count
|
||||
count = sel_string_pos[2] if sel_string_pos else sel_string_neg[2] if sel_string_neg else count
|
||||
|
||||
if count <= 2:
|
||||
# too few verts for a cut! need at least 3
|
||||
# possible fix for issue #856
|
||||
return
|
||||
|
||||
if connected:
|
||||
cl_pos = Contours_Loop(sel_loop_pos[0], True) if sel_loop_pos else None
|
||||
cl_neg = Contours_Loop(sel_loop_neg[0], True) if sel_loop_neg else None
|
||||
else:
|
||||
cl_pos = Contours_Loop(sel_string_pos[0], False) if sel_string_pos else None
|
||||
cl_neg = Contours_Loop(sel_string_neg[0], False) if sel_string_neg else None
|
||||
|
||||
if cl_pos: self.cuts += [cl_pos]
|
||||
if cl_neg: self.cuts += [cl_neg]
|
||||
|
||||
if connected:
|
||||
if cl_pos and cl_neg:
|
||||
verts0 = list(cl_pos.verts)
|
||||
verts1 = list(cl_neg.verts)
|
||||
v0 = verts0[0]
|
||||
offset = None
|
||||
for i,v1 in enumerate(verts1):
|
||||
if v0.share_edge(v1): offset = i
|
||||
if offset is not None:
|
||||
verts1 = verts1[offset:] + verts1[:offset]
|
||||
if verts0[1].share_edge(verts1[-1]):
|
||||
verts1 = [verts1[0]] + list(reversed(verts1[1:]))
|
||||
|
||||
new_edges = []
|
||||
def split_face(v0, v1):
|
||||
nonlocal new_edges
|
||||
f0 = next(iter(v0.shared_faces(v1)), None)
|
||||
if not f0:
|
||||
self.rfcontext.alert_user('Something unexpected happened in trying to create a new cut', level='warning')
|
||||
self.rfcontext.undo_cancel()
|
||||
return
|
||||
f1 = f0.split(v0, v1)
|
||||
new_edges.append(f0.shared_edge(f1))
|
||||
|
||||
nvs = []
|
||||
for v0,v2 in zip(verts0, verts1):
|
||||
e1 = v0.shared_edge(v2)
|
||||
assert e1
|
||||
intersection = cl_cut.plane.line_intersection(v0.co, v2.co)
|
||||
v0,v2 = e1.verts
|
||||
e0,v1 = e1.split()
|
||||
assert v0 in e0.verts
|
||||
assert v2 in e1.verts
|
||||
v1.co = intersection
|
||||
self.rfcontext.snap_vert(v1)
|
||||
nvs.append(v1)
|
||||
|
||||
for v0,v1 in iter_pairs(nvs, wrap=True):
|
||||
split_face(v0, v1)
|
||||
|
||||
self.rfcontext.select(new_edges)
|
||||
#self.update()
|
||||
|
||||
return
|
||||
|
||||
cl_neg.align_to(cl_pos)
|
||||
cl_cut.align_to(cl_pos)
|
||||
if options['contours uniform']:
|
||||
step_size = cl_cut.circumference / count
|
||||
dists = [0] + [step_size for i in range(count-1)]
|
||||
else:
|
||||
lc,lp,ln = cl_cut.circumference,cl_pos.circumference,cl_neg.circumference
|
||||
dists = [0] + [lc * (d0/lp + d1/ln)/2 for d0,d1 in zip(cl_pos.dists,cl_neg.dists)]
|
||||
dists = dists[:-1]
|
||||
elif cl_pos:
|
||||
cl_cut.align_to(cl_pos)
|
||||
if options['contours uniform']:
|
||||
step_size = cl_cut.circumference / count
|
||||
dists = [0] + [step_size for i in range(count-1)]
|
||||
else:
|
||||
lc,lp = cl_cut.circumference,cl_pos.circumference
|
||||
dists = [0] + [lc * (d/lp) for d in cl_pos.dists]
|
||||
dists = dists[:-1]
|
||||
elif cl_neg:
|
||||
cl_cut.align_to(cl_neg)
|
||||
if options['contours uniform']:
|
||||
step_size = cl_cut.circumference / count
|
||||
dists = [0] + [step_size for i in range(count-1)]
|
||||
else:
|
||||
lc,ln = cl_cut.circumference,cl_neg.circumference
|
||||
dists = [0] + [lc * (d/ln) for d in cl_neg.dists]
|
||||
dists = dists[:-1]
|
||||
else:
|
||||
step_size = cl_cut.circumference / count
|
||||
dists = [0] + [step_size for i in range(count-1)]
|
||||
else:
|
||||
if cl_pos and cl_neg:
|
||||
cl_neg.align_to(cl_pos)
|
||||
cl_cut.align_to(cl_pos)
|
||||
lc,lp,ln = cl_cut.circumference,cl_pos.circumference,cl_neg.circumference
|
||||
dists = [0] + [0.999 * lc * (d0/lp + d1/ln)/2 for d0,d1 in zip(cl_pos.dists,cl_neg.dists)]
|
||||
elif cl_pos:
|
||||
cl_cut.align_to(cl_pos)
|
||||
lc,lp = cl_cut.circumference,cl_pos.circumference
|
||||
dists = [0] + [0.999 * lc * (d/lp) for d in cl_pos.dists]
|
||||
elif cl_neg:
|
||||
cl_cut.align_to(cl_neg)
|
||||
lc,ln = cl_cut.circumference,cl_neg.circumference
|
||||
dists = [0] + [0.999 * lc * (d/ln) for d in cl_neg.dists]
|
||||
else:
|
||||
step_size = cl_cut.circumference / (count-1)
|
||||
dists = [0] + [0.999 * step_size for i in range(count-1)]
|
||||
dists[0] = cl_cut.offset
|
||||
|
||||
# where new verts, edges, and faces are stored
|
||||
verts,edges,faces = [],[],[]
|
||||
|
||||
if sel_loop_pos and sel_loop_neg:
|
||||
edges_between = edges_between_loops(sel_loop_pos[0], sel_loop_neg[0])
|
||||
self.rfcontext.delete_edges(edges_between)
|
||||
if sel_string_pos and sel_string_neg:
|
||||
edges_between = edges_between_loops(sel_string_pos[0], sel_string_neg[0])
|
||||
self.rfcontext.delete_edges(edges_between)
|
||||
|
||||
i,dist = 0,dists[0]
|
||||
for c0,c1 in cl_cut.iter_pts(repeat=True):
|
||||
if c0 == c1: continue
|
||||
d = (c1 - c0).length
|
||||
while dist - d <= 0:
|
||||
# create new vert between c0 and c1
|
||||
p = c0 + (c1 - c0) * (dist / d)
|
||||
self.pts += [p]
|
||||
verts += [self.rfcontext.new_vert_point(p)]
|
||||
i += 1
|
||||
if i == len(dists): break
|
||||
dist += dists[i]
|
||||
dist -= d
|
||||
if i == len(dists): break
|
||||
assert len(dists)==len(verts), '%d != %d' % (len(dists), len(verts))
|
||||
for v0,v1 in iter_pairs(verts, connected):
|
||||
edges += [self.rfcontext.new_edge((v0, v1))]
|
||||
|
||||
if cl_pos: self.rfcontext.bridge_vertloop(verts, cl_pos.verts, connected)
|
||||
if cl_neg: self.rfcontext.bridge_vertloop(verts, cl_neg.verts, connected)
|
||||
|
||||
self.rfcontext.select(edges)
|
||||
|
||||
if perform_nonmanifold_check is None or perform_nonmanifold_check:
|
||||
if options['contours non-manifold check'] and not connected and (verts[0].co - verts[-1].co).length < 0.01:
|
||||
opt_nonmanifold = '''options['contours non-manifold check']'''
|
||||
self.rfcontext.alert_user('\n'.join([
|
||||
'The stroke has cut across a non-manifold edge in the source mesh and results may not be as expected. Please double check your source for duplicate vertices, un-merged symmetry, and holes.',
|
||||
'',
|
||||
'''<label><input type="checkbox" checked="BoundBool(opt_nonmanifold)">Perform this check</label>'''
|
||||
]), level='warning')
|
||||
|
||||
@RFTool.dirty_when_done
|
||||
def fill(self):
|
||||
sel_edges = self.rfcontext.get_selected_edges()
|
||||
sel_loops = find_loops(sel_edges)
|
||||
|
||||
if len(sel_loops) != 2:
|
||||
self.rfcontext.alert_user('In order to fill, select exactly 2 loops of the same edge count')
|
||||
return
|
||||
loop0, loop1 = sel_loops
|
||||
if len(loop0) != len(loop1):
|
||||
self.rfcontext.alert_user('In order to fill, select exactly 2 loops of the same edge count')
|
||||
return
|
||||
if any(v0.share_edge(v1) for v0 in loop0 for v1 in loop1):
|
||||
self.rfcontext.alert_user('In order to fill, the 2 selected loops cannot share an edge')
|
||||
return
|
||||
|
||||
self.rfcontext.undo_push('fill')
|
||||
cl_pos = Contours_Loop(loop0, True)
|
||||
cl_neg = Contours_Loop(loop1, True)
|
||||
cl_neg.align_to(cl_pos)
|
||||
faces = self.rfcontext.bridge_vertloop(cl_neg.verts, cl_pos.verts, True)
|
||||
#self.dirty()
|
||||
#self.rfcontext.select(faces)
|
||||
|
||||
@RFTool.dirty_when_done
|
||||
def change_count(self, *, count=None, delta=None):
|
||||
assert count is not None or delta is not None, 'Contours.change_count: Must specify either count or delta!'
|
||||
sel_edges = self.rfcontext.get_selected_edges()
|
||||
loops = find_loops(sel_edges)
|
||||
strings = find_strings(sel_edges)
|
||||
if len(loops) == 1 and len(strings) == 0:
|
||||
self._change_loop_count(loops[0], count=count, delta=delta)
|
||||
elif len(strings) == 1 and len(loops) == 0:
|
||||
self._change_string_count(strings[0], count=count, delta=delta)
|
||||
else:
|
||||
print('Contours.change_count: expected either 1 loop+0 strings or 1 string+0 loops, but found %d loops and %d strings' % (len(loops), len(strings)))
|
||||
|
||||
def _change_loop_count(self, loop, *, count=None, delta=None):
|
||||
count_cur = len(loop)
|
||||
if count is not None: count_new = count
|
||||
else: count_new = count_cur + delta
|
||||
count_new = max(3, count_new)
|
||||
if count_cur == count_new: return
|
||||
if any(len(v.link_edges) != 2 for v in loop): return
|
||||
cl = Contours_Loop(loop, True)
|
||||
avg = Point.average(v.co for v in loop)
|
||||
plane = cl.plane
|
||||
ray = self.rfcontext.Point2D_to_Ray(self.rfcontext.Point_to_Point2D(avg))
|
||||
self.rfcontext.delete_edges(e for v in loop for e in v.link_edges)
|
||||
self.new_cut(ray, plane, walk_to_plane=True, count=count_new)
|
||||
|
||||
def _change_string_count(self, string, *, count=None, delta=None):
|
||||
count_cur = len(string)
|
||||
if count is not None: count_new = count
|
||||
else: count_new = count_cur + delta
|
||||
count_new = max(3, count_new)
|
||||
if count_cur == count_new: return
|
||||
if any(len(v.link_edges) != 2 for v in string[1:-1]):
|
||||
print('Contours._change_string_count: string is connected to other geometry')
|
||||
return
|
||||
if any(len(v.link_edges) != 1 for v in string[:1] + string[-1:]):
|
||||
print('Contours._change_string_count: string is connected to other geometry')
|
||||
return
|
||||
cl = Contours_Loop(string, False)
|
||||
avg = Point.average(v.co for v in string)
|
||||
plane = cl.plane
|
||||
ray = self.rfcontext.Point2D_to_Ray(self.rfcontext.Point_to_Point2D(avg))
|
||||
self.rfcontext.delete_edges(e for v in string for e in v.link_edges)
|
||||
self.new_cut(ray, plane, walk_to_plane=True, count=count_new, perform_nonmanifold_check=False)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<details>
|
||||
<summary>Contours</summary>
|
||||
<div class="contents">
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['contours uniform']''')" title="If enabled, all new vertices will be spread uniformly (equal distance) around the circumference of the new cut. If disabled, new vertices will try to match distances between vertices of the extended cut.">
|
||||
Uniform Cut
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['contours non-manifold check']''')" title="If enabled, a warning message will appear if a new cut crosses a non-manifold edge in the source mesh. This check is useful when the source mesh is not water tight.">
|
||||
Non-manifold Check
|
||||
</label>
|
||||
<div class="labeled-input-text">
|
||||
<label for="contours-initial-count">
|
||||
Initial Count
|
||||
</label>
|
||||
<input id="contours-initial-count" type="number" value="BoundInt('''options['contours count']''', min_value=3, max_value=500)" title="Number of vertices to create in a new cut.">
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="contours-current-count">
|
||||
Cut Count
|
||||
</label>
|
||||
<input id="contours-current-count" type="number" value="self._var_cut_count" title="Number of vertices in currently selected cut.">
|
||||
</div>
|
||||
<!--<label title="Number of vertices in currently selected cut." class="input-text">
|
||||
Cut Count
|
||||
<input type="text" value="BoundInt('''self.var_cut_count''', min_value=3, max_value=500)">
|
||||
</label>-->
|
||||
</div>
|
||||
</details>
|
||||
@@ -0,0 +1,55 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import os
|
||||
import re
|
||||
import math
|
||||
from itertools import chain
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix
|
||||
|
||||
from ..rftool import RFTool
|
||||
|
||||
from ...addon_common.common.boundvar import BoundBool, BoundInt
|
||||
from ...addon_common.common.utils import delay_exec
|
||||
from ...config.options import options
|
||||
|
||||
|
||||
class Contours_Props:
|
||||
@RFTool.on_init
|
||||
def init_props(self):
|
||||
self._var_init_count = BoundInt('''options['contours count']''', min_value=3, max_value=500)
|
||||
self._var_cut_count = BoundInt('''self.var_cut_count''', min_value=3, max_value=500)
|
||||
self._var_uniform_cut = BoundBool('''options['contours uniform']''')
|
||||
self._var_nonmanifold = BoundBool('''options['contours non-manifold check']''')
|
||||
|
||||
@property
|
||||
def var_cut_count(self):
|
||||
return getattr(self, '_var_cut_count_value', 0)
|
||||
@var_cut_count.setter
|
||||
def var_cut_count(self, v):
|
||||
if self.var_cut_count == v: return
|
||||
self._var_cut_count_value = v
|
||||
if self._var_cut_count.disabled: return
|
||||
self.rfcontext.undo_push('change segment count', repeatable=True)
|
||||
self.change_count(count=v)
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
class Contours_Utils:
|
||||
def filter_edge_selection(self, bme, no_verts_select=True, ratio=0.33):
|
||||
if bme.select:
|
||||
# edge is already selected
|
||||
return True
|
||||
bmv0, bmv1 = bme.verts
|
||||
s0, s1 = bmv0.select, bmv1.select
|
||||
if s0 and s1:
|
||||
# both verts are selected, so return True
|
||||
return True
|
||||
if not s0 and not s1:
|
||||
if no_verts_select:
|
||||
# neither are selected, so return True by default
|
||||
return True
|
||||
else:
|
||||
# return True if none are selected; otherwise return False
|
||||
return self.rfcontext.none_selected()
|
||||
# if mouse is at least a ratio of the distance toward unselected vert, return True
|
||||
if s1: bmv0, bmv1 = bmv1, bmv0
|
||||
p = self.actions.mouse
|
||||
p0 = self.rfcontext.Point_to_Point2D(bmv0.co)
|
||||
p1 = self.rfcontext.Point_to_Point2D(bmv1.co)
|
||||
v01 = p1 - p0
|
||||
l01 = v01.length
|
||||
d01 = v01 / l01
|
||||
dot = d01.dot(p - p0)
|
||||
return dot / l01 > ratio
|
||||
|
||||
#def get_count(self): return
|
||||
|
||||
|
||||
import math
|
||||
from itertools import chain
|
||||
from mathutils import Vector, Quaternion
|
||||
|
||||
import bpy
|
||||
|
||||
from ..rfmesh.rfmesh import RFVert
|
||||
from ...addon_common.common.utils import iter_pairs, max_index
|
||||
from ...addon_common.common.hasher import hash_cycle
|
||||
from ...addon_common.common.maths import (
|
||||
Point, Vec, Normal, Direction,
|
||||
Point2D, Vec2D,
|
||||
Plane, Frame,
|
||||
)
|
||||
from ...addon_common.common.profiler import profiler
|
||||
|
||||
|
||||
def to_point(item):
|
||||
t = type(item)
|
||||
if t is RFVert: return item.co
|
||||
if t is Point or t is Vector or t is Vec: return item
|
||||
if t is tuple: return Point(item)
|
||||
return item.co
|
||||
|
||||
|
||||
def next_edge_in_string(edge0, vert01, ignore_two_faced=False):
|
||||
faces0 = edge0.link_faces
|
||||
edges1 = vert01.link_edges
|
||||
# ignore edge0
|
||||
edges1 = [edge for edge in edges1 if edge != edge0]
|
||||
if ignore_two_faced:
|
||||
# ignore edges that have two faces already
|
||||
edges1 = [edge for edge in edges1 if len(edge.link_faces) <= 1]
|
||||
# ignore edges that share face with previous edge
|
||||
edges1 = [edge for edge in edges1 if not faces0 or not any(f in faces0 for f in edge.link_faces)]
|
||||
return edges1[0] if len(edges1) == 1 else []
|
||||
|
||||
def find_loops(edges):
|
||||
if not edges: return []
|
||||
edges = set(edges)
|
||||
touched, loops = set(), []
|
||||
|
||||
def crawl(v0, edge01):
|
||||
nonlocal edges, touched, loops
|
||||
|
||||
vert_list = []
|
||||
while True:
|
||||
# ... -- v0 -- edge01 -- v1 -- edge12 -- ...
|
||||
# > came-^-from-^ ^-going-^-to >
|
||||
vert_list.append(v0)
|
||||
touched.add(edge01)
|
||||
v1 = edge01.other_vert(v0)
|
||||
if v1 == vert_list[0]:
|
||||
# found a loop!
|
||||
loops.append(vert_list)
|
||||
return
|
||||
next_edges = [e for e in v1.link_edges if e in edges and e != edge01]
|
||||
if not next_edges:
|
||||
# could not find a loop
|
||||
return
|
||||
if len(next_edges) == 1: edge12 = next_edges[0]
|
||||
else: edge12 = next_edge_in_string(edge01, v1)
|
||||
if not edge12 or edge12 in touched or edge12 not in edges:
|
||||
# could not find a loop
|
||||
return
|
||||
v0, edge01 = v1, edge12
|
||||
|
||||
for edge in edges:
|
||||
if edge in touched: continue
|
||||
crawl(edge.verts[0], edge)
|
||||
|
||||
return loops
|
||||
|
||||
def find_parallel_loops(loop, wrap=True):
|
||||
def find_opposite_loop(loop, bmf):
|
||||
# find edge loop on opposite side of given face from given edge
|
||||
bmv0,bmv1 = loop[:2]
|
||||
bme01 = bmv0.shared_edge(bmv1)
|
||||
bme03 = next((bme for bme in bmf.neighbor_edges(bme01) if bmv0 in bme.verts), None)
|
||||
if not bme03: return None
|
||||
bmv_opposite = bme03.other_vert(bmv0)
|
||||
ploop = []
|
||||
for bmv0,bmv1 in iter_pairs(loop, wrap):
|
||||
if not bmf: return None
|
||||
if len(bmf.verts) != 4: return None
|
||||
ploop.append(bmv_opposite)
|
||||
bmv_opposite = next(iter(set(bmf.verts)-{bmv0,bmv1,bmv_opposite}), None)
|
||||
if not bmv_opposite: return None
|
||||
bme = bmv1.shared_edge(bmv_opposite)
|
||||
if not bme: return None
|
||||
bmf = next(iter(set(bme.link_faces) - {bmf}), None)
|
||||
if not ploop: return None
|
||||
if not wrap: ploop.append(bmv_opposite)
|
||||
return ploop
|
||||
|
||||
ploops = []
|
||||
|
||||
bmv0,bmv1 = loop[:2]
|
||||
bme01 = bmv0.shared_edge(bmv1)
|
||||
bmfs = [bmf for bmf in bme01.link_faces]
|
||||
touched = set()
|
||||
for bmf in bmfs:
|
||||
bme0 = bme01
|
||||
lloop = loop
|
||||
while bmf:
|
||||
if bmf in touched: break
|
||||
touched.add(bmf)
|
||||
ploop = find_opposite_loop(lloop, bmf)
|
||||
if not ploop: break
|
||||
ploops.append(ploop)
|
||||
bme1 = bmf.opposite_edge(bme0)
|
||||
if not bme1: break
|
||||
bmf = next((bmf_ for bmf_ in bme1.link_faces if bmf_ != bmf), None)
|
||||
bme0 = bme1
|
||||
lloop = ploop
|
||||
|
||||
return ploops
|
||||
|
||||
def find_strings(edges, min_length=3):
|
||||
if not edges: return []
|
||||
touched,strings = set(),[]
|
||||
|
||||
def crawl(v0, edge01, vert_list):
|
||||
nonlocal edges, touched
|
||||
# ... -- v0 -- edge01 -- v1 -- edge12 -- ...
|
||||
# came ^ from ^
|
||||
vert_list.append(v0)
|
||||
touched.add(edge01)
|
||||
v1 = edge01.other_vert(v0)
|
||||
if v1 == vert_list[0]: return []
|
||||
edge12 = next_edge_in_string(edge01, v1)
|
||||
if not edge12 or edge12 not in edges: return vert_list + [v1]
|
||||
return crawl(v1, edge12, vert_list)
|
||||
|
||||
for edge in edges:
|
||||
if edge in touched: continue
|
||||
vert_list0 = crawl(edge.verts[0], edge, [])
|
||||
vert_list1 = crawl(edge.verts[1], edge, [])
|
||||
vert_list = list(reversed(vert_list0)) + vert_list1[2:]
|
||||
if len(vert_list) >= min_length: strings.append(vert_list)
|
||||
|
||||
return strings
|
||||
|
||||
def find_cycles(edges, max_loops=10):
|
||||
# searches through edges to find loops
|
||||
# first, break into connected components
|
||||
# then, find all the junctions (verts with more than two connected edges)
|
||||
# sequence of edges between junctions can be reduced to single edge
|
||||
# find cycles in graph
|
||||
|
||||
if not edges: return []
|
||||
|
||||
vert_edges = {}
|
||||
for edge in edges:
|
||||
v0,v1 = edge.verts
|
||||
vert_edges[v0] = vert_edges.get(v0, []) + [(edge,v1)]
|
||||
vert_edges[v1] = vert_edges.get(v1, []) + [(edge,v0)]
|
||||
touched_edges = set()
|
||||
touched_verts = set()
|
||||
cycles = []
|
||||
cycle_hashes = set()
|
||||
def crawl(v0, vert_list):
|
||||
touched_verts.add(v0)
|
||||
vert_list.append(v0)
|
||||
for edge,v1 in vert_edges[v0]:
|
||||
if edge in touched_edges: continue
|
||||
touched_edges.add(edge)
|
||||
if v1 in vert_list:
|
||||
# found cycle!
|
||||
cycle = list(reversed(vert_list))
|
||||
while cycle[-1] != v1: cycle.pop()
|
||||
h = hash_cycle(cycle)
|
||||
if h not in cycle_hashes:
|
||||
cycle_hashes.add(h)
|
||||
cycles.append(cycle)
|
||||
else:
|
||||
crawl(v1, vert_list)
|
||||
touched_edges.remove(edge)
|
||||
if len(cycles) == max_loops: return
|
||||
vert_list.pop()
|
||||
for v in vert_edges.keys():
|
||||
if v in touched_verts: continue
|
||||
crawl(v, [])
|
||||
if len(cycles) == max_loops: print('max loop count reached')
|
||||
return cycles
|
||||
|
||||
def edges_of_loop(vert_loop):
|
||||
edges = []
|
||||
for v0,v1 in iter_pairs(vert_loop, True):
|
||||
e0 = set(v0.link_edges)
|
||||
e1 = set(v1.link_edges)
|
||||
edges += list(e0 & e1)
|
||||
return edges
|
||||
|
||||
def verts_of_loop(edge_loop):
|
||||
verts = []
|
||||
for e0,e1 in iter_pairs(edge_loop, False):
|
||||
if not verts:
|
||||
v0 = e0.shared_vert(e1)
|
||||
verts += [e0.other_vert(v0), v0]
|
||||
verts += [e1.other_vert(verts[-1])]
|
||||
if len(verts) > 1 and verts[0] == verts[-1]: return verts[:-1]
|
||||
return verts
|
||||
|
||||
def loop_plane(vert_loop):
|
||||
# average co is pt on plane
|
||||
# average cross product (point in same direction) is normal
|
||||
if not vert_loop: return None
|
||||
vert_loop = [to_point(v) for v in vert_loop]
|
||||
pt = sum((Vector(vert) for vert in vert_loop), Vector()) / len(vert_loop)
|
||||
n,cnt = None,0
|
||||
for vert0,vert1 in zip(vert_loop[:-1], vert_loop[1:]):
|
||||
c = Vec((vert0-pt).cross(vert1-pt)).normalize()
|
||||
n = n+c if n else c
|
||||
if not n: return Plane(pt, Normal())
|
||||
return Plane(pt, Normal(n).normalize())
|
||||
|
||||
def loop_radius(vert_loop):
|
||||
pt = sum((Vector(to_point(vert)) for vert in vert_loop), Vector()) / len(vert_loop)
|
||||
rad = sum((to_point(vert) - pt).length for vert in vert_loop) / len(vert_loop)
|
||||
return rad
|
||||
|
||||
def loop_length(vert_loop):
|
||||
return sum((to_point(v0)-to_point(v1)).length for v0,v1 in zip(vert_loop, chain(vert_loop[1:], vert_loop[:1])))
|
||||
|
||||
def loops_connected(vert_loop0, vert_loop1):
|
||||
if not vert_loop0 or not vert_loop1: return False
|
||||
v0 = vert_loop0
|
||||
v0_connected = { e.other_vert(v0) for e in v0.link_edges }
|
||||
return any(v1 in v0_connected for v1 in vert_loop1)
|
||||
|
||||
def edges_between_loops(vert_loop0, vert_loop1):
|
||||
loop1 = set(vert_loop1)
|
||||
return [e for v0 in vert_loop0 for e in v0.link_edges if e.other_vert(v0) in loop1]
|
||||
|
||||
def faces_between_loops(vert_loop0, vert_loop1):
|
||||
loop1 = set(vert_loop1)
|
||||
return [f for v0 in vert_loop0 for f in v0.link_faces if any(fv in loop1 for fv in f.verts)]
|
||||
|
||||
def string_length(vert_loop):
|
||||
return sum((to_point(v0)-to_point(v1)).length for v0,v1 in zip(vert_loop[:-1], vert_loop[1:]))
|
||||
|
||||
def project_loop_to_plane(vert_loop, plane):
|
||||
return [plane.project(to_point(v)) for v in vert_loop]
|
||||
|
||||
|
||||
|
||||
class Contours_Loop:
|
||||
def __init__(self, vert_loop, connected, offset=0):
|
||||
self.connected = connected
|
||||
self.set_vert_loop(vert_loop, offset)
|
||||
|
||||
def __repr__(self):
|
||||
return '<Contours_Loop: %d,%s,%s>' % (len(self.verts), str(self.connected), str(self.verts))
|
||||
|
||||
# @profiler.function
|
||||
def set_vert_loop(self, vert_loop, offset):
|
||||
self.verts = vert_loop
|
||||
self.offset = offset
|
||||
self.pts = [to_point(bmv) for bmv in self.verts]
|
||||
self.count = len(self.pts)
|
||||
self.plane = loop_plane(self.pts)
|
||||
if not self.connected:
|
||||
self.plane.o = self.pts[0] + (self.pts[-1] - self.pts[0]) / 2
|
||||
self.up_dir = Direction(self.pts[0] - self.plane.o)
|
||||
self.frame = Frame.from_plane(self.plane, y=self.up_dir)
|
||||
|
||||
proj = self.plane.project
|
||||
self.dists = [(proj(p0)-proj(p1)).length for p0,p1 in iter_pairs(self.pts, self.connected)]
|
||||
self.proj_dists = [self.plane.signed_distance_to(p) for p in self.pts]
|
||||
self.circumference = sum(self.dists)
|
||||
self.radius = sum(self.w2l_point(pt).length for pt in self.pts) / self.count
|
||||
|
||||
def get_origin(self): return self.plane.o
|
||||
def get_normal(self): return self.plane.n
|
||||
def get_local_by_index(self, idx): return self.w2l_point(self.pts[idx])
|
||||
def w2l_point(self, co): return self.frame.w2l_point(to_point(co))
|
||||
def l2w_point(self, co): return self.frame.l2w_point(to_point(co))
|
||||
def get_index_of_top(self, pts):
|
||||
pts_local = [self.w2l_point(pt+self.frame.o) for pt in pts]
|
||||
idx = max_index(pts_local, key=lambda pt:pt.y)
|
||||
t = pts_local[idx]
|
||||
#print(pts_local, idx, t)
|
||||
offset = ((math.pi/2 - math.atan2(t.y, t.x)) * self.circumference / (math.pi*2)) % self.circumference
|
||||
return (idx,offset)
|
||||
|
||||
def align_to(self, other):
|
||||
n0, n1 = self.get_normal(), other.get_normal()
|
||||
is_opposite = n0.dot(n1) < 0
|
||||
vert_loop = list(reversed(self.verts)) if is_opposite else self.verts
|
||||
if not self.connected:
|
||||
self.set_vert_loop(vert_loop, 0)
|
||||
return
|
||||
if is_opposite: n0 = -n0
|
||||
|
||||
# issue #659
|
||||
angle = 0 if n0.length_squared == 0 or n1.length_squared == 0 else n0.angle(n1)
|
||||
q = Quaternion(n0.cross(n1), angle)
|
||||
|
||||
# rotate to align "topmost" vertex
|
||||
rel_pos = [Vec(q @ (to_point(p) - self.frame.o)) for p in vert_loop]
|
||||
rot_by,offset = other.get_index_of_top(rel_pos)
|
||||
vert_loop = vert_loop[rot_by:] + vert_loop[:rot_by]
|
||||
offset = (offset * self.circumference / other.circumference)
|
||||
self.set_vert_loop(vert_loop, offset)
|
||||
|
||||
def get_closest_point(self, point):
|
||||
point = to_point(point)
|
||||
cp,cd = None,None
|
||||
for p0,p1 in iter_pairs(self.pts, self.connected):
|
||||
diff = p1 - p0
|
||||
l = diff.length
|
||||
d = diff / l
|
||||
pp = p0 + d * max(0, min(l, (point - p0).dot(d)))
|
||||
dist = (point - pp).length
|
||||
if not cp or dist < cd: cp,cd = pp,dist
|
||||
return cp
|
||||
|
||||
def get_points_relative_to(self, other):
|
||||
scale = other.radius / self.radius
|
||||
return [other.l2w_point(Vector(self.w2l_point(pt)) * scale) for pt in self.pts]
|
||||
|
||||
def iter_pts(self, repeat=False):
|
||||
return iter_pairs(self.pts, self.connected, repeat=repeat)
|
||||
|
||||
def move_2D(self, xy_delta:Vec2D):
|
||||
pass
|
||||
@@ -0,0 +1,309 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import time
|
||||
import random
|
||||
|
||||
from mathutils.geometry import intersect_line_line_2d as intersect2d_segment_segment
|
||||
|
||||
from ..rftool import RFTool
|
||||
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
|
||||
from ..rfwidgets.rfwidget_hidden import RFWidget_Hidden_Factory
|
||||
from ..rfmesh.rfmesh_wrapper import RFVert, RFEdge, RFFace
|
||||
|
||||
from ...addon_common.common.drawing import (
|
||||
CC_DRAW,
|
||||
CC_2D_POINTS,
|
||||
CC_2D_LINES, CC_2D_LINE_LOOP,
|
||||
CC_2D_TRIANGLES, CC_2D_TRIANGLE_FAN,
|
||||
)
|
||||
from ...addon_common.common import gpustate
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.maths import Point, Point2D, Vec2D, Vec, Direction2D, intersection2d_line_line, closest2d_point_segment
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.utils import iter_pairs
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.boundvar import BoundBool, BoundInt, BoundFloat, BoundString
|
||||
from ...addon_common.common.decorators import timed_call
|
||||
from ...addon_common.common.drawing import DrawCallbacks
|
||||
|
||||
from .knife_insert import Knife_Insert
|
||||
|
||||
from ...config.options import options, themes
|
||||
|
||||
|
||||
class Knife(RFTool, Knife_Insert):
|
||||
name = 'Knife'
|
||||
description = 'Cut complex topology into existing geometry on vertex-by-vertex basis'
|
||||
icon = 'knife-icon.png'
|
||||
help = 'knife.md'
|
||||
shortcut = 'knife tool'
|
||||
quick_shortcut = 'knife quick'
|
||||
statusbar = '{{insert}} Insert'
|
||||
ui_config = 'knife_options.html'
|
||||
|
||||
RFWidget_Default = RFWidget_Default_Factory.create()
|
||||
RFWidget_Knife = RFWidget_Default_Factory.create(cursor='KNIFE')
|
||||
RFWidget_Move = RFWidget_Default_Factory.create(cursor='HAND')
|
||||
RFWidget_Hidden = RFWidget_Hidden_Factory.create()
|
||||
|
||||
@RFTool.on_init
|
||||
def init(self):
|
||||
self.rfwidgets = {
|
||||
'default': self.RFWidget_Default(self),
|
||||
'knife': self.RFWidget_Knife(self),
|
||||
'hover': self.RFWidget_Move(self),
|
||||
'hidden': self.RFWidget_Hidden(self),
|
||||
}
|
||||
self.rfwidget = None
|
||||
self.knife_start = None
|
||||
self.update_hovered()
|
||||
|
||||
def _fsm_in_main(self):
|
||||
# needed so main actions using Ctrl (ex: undo, redo, save) can still work
|
||||
return self._fsm.state in {'main', 'insert'}
|
||||
|
||||
@RFTool.on_reset
|
||||
def reset(self):
|
||||
self.quickswitch = False
|
||||
self.hovering_sel_geom = None
|
||||
|
||||
@RFTool.on_events('reset', 'target change', 'view change', 'mouse move')
|
||||
@RFTool.once_per_frame
|
||||
@FSM.onlyinstate('main')
|
||||
def update_hovered(self):
|
||||
self.hovering_sel_geom = self.rfcontext.accel_nearest2D_geom(max_dist=options['action dist'], selected_only=True)
|
||||
|
||||
@FSM.on_state('main', 'enter')
|
||||
def main_enter(self):
|
||||
self.update_hovered()
|
||||
|
||||
@FSM.on_state('main')
|
||||
def main(self):
|
||||
if self.hovering_sel_geom and not self.hovering_sel_geom.is_valid: self.hoving_sel_geom = None
|
||||
|
||||
if self.actions.using_onlymods('insert'):
|
||||
return 'insert'
|
||||
|
||||
if self.hovering_sel_geom:
|
||||
self.set_widget('hover')
|
||||
else:
|
||||
self.set_widget('default')
|
||||
|
||||
if self.handle_inactive_passthrough(): return
|
||||
|
||||
if self.hovering_sel_geom and self.actions.pressed('action'):
|
||||
self.rfcontext.undo_push('grab')
|
||||
self.prep_move(
|
||||
action_confirm=(lambda: self.actions.released('action', ignoremods=True)),
|
||||
)
|
||||
return 'move after select'
|
||||
|
||||
if self.actions.pressed({'select path add'}):
|
||||
return self.rfcontext.select_path(
|
||||
{'edge'},
|
||||
kwargs_select={'supparts': False},
|
||||
)
|
||||
|
||||
if self.actions.pressed({'select paint', 'select paint add'}, unpress=False):
|
||||
sel_only = self.actions.pressed('select paint')
|
||||
self.actions.unpress()
|
||||
return self.rfcontext.setup_smart_selection_painting(
|
||||
{'vert','edge','face'},
|
||||
use_select_tool=True,
|
||||
selecting=not sel_only,
|
||||
deselect_all=sel_only,
|
||||
kwargs_select={'supparts': False},
|
||||
kwargs_deselect={'subparts': False},
|
||||
)
|
||||
|
||||
if self.actions.pressed({'select single', 'select single add'}, unpress=False):
|
||||
sel_only = self.actions.pressed('select single')
|
||||
self.actions.unpress()
|
||||
sel = self.rfcontext.accel_nearest2D_geom(max_dist=options['select dist'])
|
||||
if not sel_only and not sel: return
|
||||
self.rfcontext.undo_push('select')
|
||||
if sel_only: self.rfcontext.deselect_all()
|
||||
if not sel: return
|
||||
if sel.select: self.rfcontext.deselect(sel, subparts=False)
|
||||
else: self.rfcontext.select(sel, supparts=False, only=sel_only)
|
||||
return
|
||||
|
||||
if self.rfcontext.actions.pressed('knife reset'):
|
||||
self.knife_start = None
|
||||
self.rfcontext.deselect_all()
|
||||
return
|
||||
|
||||
if self.actions.pressed('grab'):
|
||||
self.rfcontext.undo_push('move grabbed')
|
||||
self.prep_move()
|
||||
return 'move'
|
||||
|
||||
|
||||
@FSM.on_state('move after select')
|
||||
@RFTool.dirty_when_done
|
||||
def modal_move_after_select(self):
|
||||
if self.actions.released('action'):
|
||||
return 'main'
|
||||
|
||||
if (self.actions.mouse - self.mousedown).length >= self.rfcontext.drawing.scale(options['move dist']):
|
||||
self.rfcontext.undo_push('move after select')
|
||||
return 'move'
|
||||
|
||||
def prep_move(self, *, bmverts=None, bmverts_xys=None, action_confirm=None, action_cancel=None):
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
if bmverts_xys is not None:
|
||||
self.bmverts_xys = bmverts_xys
|
||||
self.bmverts = [bmv for (bmv, _) in self.bmverts_xys]
|
||||
else:
|
||||
self.bmverts = bmverts if bmverts is not None else self.rfcontext.get_selected_verts()
|
||||
self.bmverts_xys = [
|
||||
(bmv, xy)
|
||||
for bmv in self.bmverts
|
||||
if bmv and bmv.is_valid and (xy := Point_to_Point2D(bmv.co)) is not None
|
||||
]
|
||||
self.move_actions = {
|
||||
'confirm': action_confirm or (lambda: self.actions.pressed('confirm')),
|
||||
'cancel': action_cancel or (lambda: self.actions.pressed('cancel')),
|
||||
}
|
||||
self.mousedown = self.actions.mouse
|
||||
self.state_after_move = self._fsm.state
|
||||
|
||||
@FSM.on_state('move', 'enter')
|
||||
def move_enter(self):
|
||||
self.move_opts = {
|
||||
'vis_accel': self.rfcontext.get_custom_vis_accel(selection_only=False, include_edges=False, include_faces=False, symmetry=False),
|
||||
}
|
||||
|
||||
if options['hide cursor on tweak']: self.set_widget('hidden')
|
||||
|
||||
# filter out any deleted bmverts (issue #1075) or bmverts that are not on screen
|
||||
self.bmverts_xys = [(bmv, xy) for (bmv, xy) in self.bmverts_xys if bmv and bmv.is_valid and xy]
|
||||
self.bmverts = [bmv for (bmv, _) in self.bmverts_xys]
|
||||
self.last_delta = None
|
||||
self.rfcontext.split_target_visualization_selected()
|
||||
self.rfcontext.set_accel_defer(True)
|
||||
self.rfcontext.fast_update_timer.enable(True)
|
||||
|
||||
@FSM.on_state('move')
|
||||
def modal_move(self):
|
||||
if self.move_actions['confirm']():
|
||||
if options['knife automerge']:
|
||||
self.rfcontext.merge_verts_by_dist(self.bmverts, options['knife merge dist'])
|
||||
return self.state_after_move
|
||||
|
||||
if self.move_actions['cancel']():
|
||||
self.rfcontext.undo_cancel()
|
||||
return self.state_after_move
|
||||
|
||||
@RFTool.on_mouse_move
|
||||
@RFTool.once_per_frame
|
||||
@FSM.onlyinstate('move')
|
||||
def modal_move_update(self):
|
||||
delta = Vec2D(self.actions.mouse - self.mousedown)
|
||||
if delta == self.last_delta: return
|
||||
self.last_delta = delta
|
||||
set2D_vert = self.rfcontext.set2D_vert
|
||||
|
||||
for bmv,xy in self.bmverts_xys:
|
||||
if not xy: continue
|
||||
xy_updated = xy + delta
|
||||
# check if xy_updated is "close" to any visible verts (in image plane)
|
||||
# if so, snap xy_updated to vert position (in image plane)
|
||||
if options['knife automerge']:
|
||||
bmv1,d = self.rfcontext.accel_nearest2D_vert(point=xy_updated, vis_accel=self.move_opts['vis_accel'], max_dist=options['knife merge dist'])
|
||||
if bmv1 is None:
|
||||
set2D_vert(bmv, xy_updated)
|
||||
continue
|
||||
xy1 = self.rfcontext.Point_to_Point2D(bmv1.co)
|
||||
if not xy1:
|
||||
set2D_vert(bmv, xy_updated)
|
||||
continue
|
||||
set2D_vert(bmv, xy1)
|
||||
else:
|
||||
set2D_vert(bmv, xy_updated)
|
||||
|
||||
self.rfcontext.update_verts_faces(self.bmverts)
|
||||
self.rfcontext.dirty()
|
||||
tag_redraw_all('knife mouse move')
|
||||
|
||||
@FSM.on_state('move', 'exit')
|
||||
def move_exit(self):
|
||||
self.rfcontext.fast_update_timer.enable(False)
|
||||
self.rfcontext.set_accel_defer(False)
|
||||
self.rfcontext.clear_split_target_visualization()
|
||||
|
||||
|
||||
# def _get_edge_quad_verts(self):
|
||||
# '''
|
||||
# this function is used in quad-only mode to find positions of quad verts based on selected edge and mouse position
|
||||
# a Desmos construction of how this works: https://www.desmos.com/geometry/5w40xowuig
|
||||
# '''
|
||||
# e0,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
|
||||
# if not e0: return (None, None, None, None)
|
||||
# bmv0,bmv1 = e0.verts
|
||||
# xy0 = self.rfcontext.Point_to_Point2D(bmv0.co)
|
||||
# xy1 = self.rfcontext.Point_to_Point2D(bmv1.co)
|
||||
# d01 = (xy0 - xy1).length
|
||||
# mid01 = xy0 + (xy1 - xy0) / 2
|
||||
# mid23 = self.actions.mouse
|
||||
# mid0123 = mid01 + (mid23 - mid01) / 2
|
||||
# between = mid23 - mid01
|
||||
# if between.length < 0.0001: return (None, None, None, None)
|
||||
# perp = Direction2D((-between.y, between.x))
|
||||
# if perp.dot(xy1 - xy0) < 0: perp.reverse()
|
||||
# #pts = intersect_line_line(xy0, xy1, mid0123, mid0123 + perp)
|
||||
# #if not pts: return (None, None, None, None)
|
||||
# #intersection = pts[1]
|
||||
# intersection = intersection2d_line_line(xy0, xy1, mid0123, mid0123 + perp)
|
||||
# if not intersection: return (None, None, None, None)
|
||||
# intersection = Point2D(intersection)
|
||||
|
||||
# toward = Direction2D(mid23 - intersection)
|
||||
# if toward.dot(perp) < 0: d01 = -d01
|
||||
|
||||
# # push intersection out just a bit to make it more stable (prevent crossing) when |between| < d01
|
||||
# between_len = between.length * Direction2D(xy1 - xy0).dot(perp)
|
||||
|
||||
# for tries in range(32):
|
||||
# v = toward * (d01 / 2)
|
||||
# xy2, xy3 = mid23 + v, mid23 - v
|
||||
|
||||
# # try to prevent quad from crossing
|
||||
# v03 = xy3 - xy0
|
||||
# if v03.dot(between) < 0 or v03.length < between_len:
|
||||
# xy3 = xy0 + Direction2D(v03) * (between_len * (-1 if v03.dot(between) < 0 else 1))
|
||||
# v12 = xy2 - xy1
|
||||
# if v12.dot(between) < 0 or v12.length < between_len:
|
||||
# xy2 = xy1 + Direction2D(v12) * (between_len * (-1 if v12.dot(between) < 0 else 1))
|
||||
|
||||
# if self.rfcontext.raycast_sources_Point2D(xy2)[0] and self.rfcontext.raycast_sources_Point2D(xy3)[0]: break
|
||||
# d01 /= 2
|
||||
# else:
|
||||
# return (None, None, None, None)
|
||||
|
||||
# nearest_vert,_ = self.rfcontext.nearest2D_vert(point=xy2, verts=self.vis_verts, max_dist=options['knife merge dist'])
|
||||
# if nearest_vert: xy2 = self.rfcontext.Point_to_Point2D(nearest_vert.co)
|
||||
# nearest_vert,_ = self.rfcontext.nearest2D_vert(point=xy3, verts=self.vis_verts, max_dist=options['knife merge dist'])
|
||||
# if nearest_vert: xy3 = self.rfcontext.Point_to_Point2D(nearest_vert.co)
|
||||
|
||||
# return (xy0, xy1, xy2, xy3)
|
||||
@@ -0,0 +1,416 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import time
|
||||
import random
|
||||
|
||||
from mathutils.geometry import intersect_line_line_2d as intersect2d_segment_segment
|
||||
|
||||
from ..rftool import RFTool
|
||||
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
|
||||
from ..rfwidgets.rfwidget_hidden import RFWidget_Hidden_Factory
|
||||
from ..rfmesh.rfmesh_wrapper import RFVert, RFEdge, RFFace
|
||||
|
||||
from ...addon_common.common.drawing import (
|
||||
CC_DRAW,
|
||||
CC_2D_POINTS,
|
||||
CC_2D_LINES, CC_2D_LINE_LOOP,
|
||||
CC_2D_TRIANGLES, CC_2D_TRIANGLE_FAN,
|
||||
)
|
||||
from ...addon_common.common import gpustate
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.maths import Point, Point2D, Vec2D, Vec, Direction2D, intersection2d_line_line, closest2d_point_segment
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.utils import iter_pairs
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.boundvar import BoundBool, BoundInt, BoundFloat, BoundString
|
||||
from ...addon_common.common.decorators import timed_call
|
||||
from ...addon_common.common.drawing import DrawCallbacks
|
||||
|
||||
|
||||
from ...config.options import options, themes
|
||||
|
||||
|
||||
class Knife_Insert():
|
||||
@RFTool.on_quickswitch_start
|
||||
def quickswitch_start(self):
|
||||
self.quickswitch = True
|
||||
self._fsm.force_set_state('insert')
|
||||
|
||||
@RFTool.on_events('target change')
|
||||
@FSM.onlyinstate('insert')
|
||||
@RFTool.not_while_navigating
|
||||
def gather_selection(self):
|
||||
self.sel_verts, self.sel_edges, self.sel_faces = self.rfcontext.get_selected_geom()
|
||||
|
||||
@RFTool.on_events('target change', 'view change')
|
||||
@FSM.onlyinstate('insert')
|
||||
@RFTool.not_while_navigating
|
||||
def gather_visible(self):
|
||||
self.vis_verts, self.vis_edges, self.vis_faces = self.rfcontext.get_vis_geom()
|
||||
|
||||
def gather_all(self):
|
||||
self.gather_selection()
|
||||
self.gather_visible()
|
||||
|
||||
@RFTool.on_events('mouse move')
|
||||
@FSM.onlyinstate('insert')
|
||||
@RFTool.not_while_navigating
|
||||
def update_knife(self):
|
||||
tag_redraw_all('Knife mousemove')
|
||||
|
||||
|
||||
def ensure_all_valid(self):
|
||||
self.sel_verts = [v for v in self.sel_verts if v.is_valid]
|
||||
self.sel_edges = [e for e in self.sel_edges if e.is_valid]
|
||||
self.sel_faces = [f for f in self.sel_faces if f.is_valid]
|
||||
|
||||
self.vis_verts = [v for v in self.vis_verts if v.is_valid]
|
||||
self.vis_edges = [e for e in self.vis_edges if e.is_valid]
|
||||
self.vis_faces = [f for f in self.vis_faces if f.is_valid]
|
||||
|
||||
|
||||
@FSM.on_state('insert', 'enter')
|
||||
def insert_enter(self):
|
||||
self.gather_all()
|
||||
self.knife_start = None
|
||||
self.set_widget('knife')
|
||||
self.rfcontext.fast_update_timer.enable(True)
|
||||
|
||||
if not self.quickswitch:
|
||||
self.knife_actions = {
|
||||
'insert': (lambda: self.actions.pressed('insert')),
|
||||
'done': (lambda: not self.actions.using_onlymods('insert')),
|
||||
'move': (lambda: self.actions.released('insert', ignoremods=True)),
|
||||
}
|
||||
else:
|
||||
self.knife_actions = {
|
||||
'insert': (lambda: self.actions.pressed('quick insert')),
|
||||
'done': (lambda: any([
|
||||
self.actions.pressed('cancel'),
|
||||
self.actions.pressed('confirm', ignoremouse=True),
|
||||
self.actions.pressed('confirm quick'),
|
||||
])),
|
||||
'move': (lambda: self.actions.released('quick insert', ignoremods=True)),
|
||||
}
|
||||
|
||||
@FSM.on_state('insert')
|
||||
def insert_main(self):
|
||||
# if self.handle_inactive_passthrough(): return
|
||||
|
||||
if self.knife_actions['insert']():
|
||||
self.rfcontext.undo_push('insert')
|
||||
return self._insert()
|
||||
|
||||
if self.knife_actions['done']():
|
||||
return 'main'
|
||||
|
||||
if self.rfcontext.actions.pressed('knife reset'):
|
||||
self.knife_start = None
|
||||
self.rfcontext.deselect_all()
|
||||
return
|
||||
|
||||
if self.rfcontext.actions.pressed({'select all', 'deselect all'}):
|
||||
self.rfcontext.undo_push('deselect all')
|
||||
self.rfcontext.deselect_all()
|
||||
return
|
||||
|
||||
@FSM.on_state('insert', 'exit')
|
||||
def insert_exit(self):
|
||||
self.rfcontext.fast_update_timer.enable(False)
|
||||
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate('insert')
|
||||
@RFTool.not_while_navigating
|
||||
def draw_postpixel(self):
|
||||
# TODO: put all logic into set_next_state(), such as vertex snapping, edge splitting, etc.
|
||||
|
||||
# make sure that all our data structs contain valid data (hasn't been deleted)
|
||||
self.ensure_all_valid()
|
||||
|
||||
hit_pos = self.actions.hit_pos
|
||||
|
||||
bmv, _ = self.rfcontext.accel_nearest2D_vert(max_dist=options['knife snap dist'])
|
||||
bme, _ = self.rfcontext.accel_nearest2D_edge(max_dist=options['knife snap dist'])
|
||||
bmf, _ = self.rfcontext.accel_nearest2D_face(max_dist=options['knife snap dist'])
|
||||
|
||||
if self.knife_start is None and len(self.sel_verts) == 0:
|
||||
next_state = 'knife start'
|
||||
elif bme and any(v.select for v in bme.verts):
|
||||
# special case that we are hovering an edge has a selected vert (should split edge!)
|
||||
next_state = 'knife start'
|
||||
else:
|
||||
next_state = 'knife cut'
|
||||
|
||||
gpustate.blend('ALPHA')
|
||||
CC_DRAW.stipple(pattern=[4,4])
|
||||
CC_DRAW.point_size(8)
|
||||
CC_DRAW.line_width(2)
|
||||
|
||||
match next_state:
|
||||
case 'knife start':
|
||||
if bmv:
|
||||
p, c = self.rfcontext.Point_to_Point2D(bmv.co), themes['active']
|
||||
elif bme:
|
||||
bmv1, bmv2 = bme.verts
|
||||
if hit_pos:
|
||||
self.draw_lines([bmv1.co, hit_pos])
|
||||
self.draw_lines([bmv2.co, hit_pos])
|
||||
p, c = self.actions.mouse, themes['new']
|
||||
elif bmf:
|
||||
p, c = self.actions.mouse, themes['new']
|
||||
else:
|
||||
p, c = None, None
|
||||
if p and c:
|
||||
with Globals.drawing.draw(CC_2D_POINTS) as draw:
|
||||
draw.color(c)
|
||||
draw.vertex(p)
|
||||
case 'knife cut':
|
||||
knife_start = self.knife_start or self.rfcontext.Point_to_Point2D(next(iter(self.sel_verts)).co)
|
||||
with Globals.drawing.draw(CC_2D_LINES) as draw:
|
||||
draw.color(themes['stroke'])
|
||||
draw.vertex(knife_start)
|
||||
draw.vertex(self.actions.mouse)
|
||||
crosses = self._get_crosses(knife_start, self.actions.mouse)
|
||||
if not crosses: return
|
||||
if bmf:
|
||||
dist_to_last = (crosses[-1][0] - self.actions.mouse).length
|
||||
if dist_to_last > self.rfcontext.drawing.scale(options['knife snap dist']):
|
||||
crosses += [(self.actions.mouse, bmf, 1.0)]
|
||||
elif bme:
|
||||
dist_to_last = (crosses[-1][0] - self.actions.mouse).length
|
||||
if dist_to_last > self.rfcontext.drawing.scale(options['knife snap dist']):
|
||||
crosses += [(self.actions.mouse, bme, 1.0)]
|
||||
self.draw_crosses(crosses)
|
||||
if bmv:
|
||||
pass
|
||||
elif bme:
|
||||
bmv1, bmv2 = bme.verts
|
||||
if hit_pos:
|
||||
self.draw_lines([bmv1.co, hit_pos])
|
||||
self.draw_lines([bmv2.co, hit_pos])
|
||||
c = themes['new']
|
||||
p = self.actions.mouse
|
||||
# print(crosses)
|
||||
|
||||
|
||||
|
||||
@RFTool.dirty_when_done
|
||||
def _insert(self):
|
||||
bmv, _ = self.rfcontext.accel_nearest2D_vert(max_dist=options['knife snap dist'])
|
||||
bme, _ = self.rfcontext.accel_nearest2D_edge(max_dist=options['knife snap dist'])
|
||||
bmf, _ = self.rfcontext.accel_nearest2D_face(max_dist=options['knife snap dist'])
|
||||
|
||||
if self.knife_start is None and len(self.sel_verts) == 0:
|
||||
next_state = 'knife start'
|
||||
elif bme and any(v.select for v in bme.verts):
|
||||
# special case that we are hovering an edge has a selected vert (should split edge!)
|
||||
next_state = 'knife start'
|
||||
else:
|
||||
next_state = 'knife cut'
|
||||
|
||||
match next_state:
|
||||
case 'knife start':
|
||||
if bmv:
|
||||
# just select the hovered vert
|
||||
self.rfcontext.select(bmv)
|
||||
elif bme:
|
||||
# split the hovered edge
|
||||
bmv = self.rfcontext.new2D_vert_mouse()
|
||||
if not bmv:
|
||||
self.rfcontext.undo_cancel()
|
||||
return
|
||||
bme0,bmv2 = bme.split()
|
||||
bmv.merge(bmv2)
|
||||
self.rfcontext.select(bmv)
|
||||
elif bmf:
|
||||
# add point at mouse
|
||||
bmv = self.rfcontext.new2D_vert_mouse()
|
||||
self.rfcontext.select(bmv)
|
||||
else:
|
||||
self.knife_start = self.actions.mouse
|
||||
self.rfcontext.undo_cancel() # remove undo, because no geometry was changed
|
||||
self.gather_all()
|
||||
return
|
||||
|
||||
self.prep_move(
|
||||
bmverts_xys=([(bmv, self.actions.mouse)] if bmv else []),
|
||||
action_confirm=self.knife_actions['move'],
|
||||
)
|
||||
return 'move'
|
||||
|
||||
case 'knife cut':
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
knife_start = self.knife_start or Point_to_Point2D(next(iter(self.sel_verts)).co)
|
||||
knife_start_face = self.rfcontext.accel_nearest2D_face(point=knife_start, max_dist=options['knife snap dist'])[0]
|
||||
crosses = self._get_crosses(knife_start, self.actions.mouse)
|
||||
# add additional point if mouse is hovering a face or edge
|
||||
if bmf:
|
||||
dist_to_last = (crosses[-1][0] - self.actions.mouse).length if crosses else float('inf')
|
||||
if dist_to_last > self.rfcontext.drawing.scale(options['knife snap dist']):
|
||||
crosses += [(self.actions.mouse, bmf, None)]
|
||||
elif bme:
|
||||
dist_to_last = (crosses[-1][0] - self.actions.mouse).length if crosses else float('inf')
|
||||
if dist_to_last > self.rfcontext.drawing.scale(options['knife snap dist']):
|
||||
crosses += [(self.actions.mouse, bme, None)]
|
||||
|
||||
if not crosses:
|
||||
self.rfcontext.undo_cancel() # remove undo, because no geometry was changed
|
||||
self.knife_start = self.actions.mouse
|
||||
return
|
||||
|
||||
prev = None
|
||||
pre_e = -1
|
||||
pre_p = None
|
||||
unfaced_verts = []
|
||||
bmfs_to_shatter = set()
|
||||
for p,e,d in crosses:
|
||||
if type(e) is RFVert:
|
||||
cur = e
|
||||
else:
|
||||
cur = self.rfcontext.new2D_vert_point(p)
|
||||
if type(e) is RFEdge:
|
||||
eo,bmv = e.split()
|
||||
if cur:
|
||||
cur.merge(bmv)
|
||||
else:
|
||||
cur = bmv
|
||||
elif type(e) is RFFace:
|
||||
pass
|
||||
if prev:
|
||||
cur_faces = set(cur.link_faces)
|
||||
cur_under = cur_faces
|
||||
if not cur_under:
|
||||
cur_under = {self.rfcontext.accel_nearest2D_face(point=p, max_dist=options['knife snap dist'])[0]}
|
||||
pre_faces = set(prev.link_faces)
|
||||
pre_under = pre_faces
|
||||
if not pre_under:
|
||||
pre_under = {self.rfcontext.accel_nearest2D_face(point=pre_p, max_dist=options['knife snap dist'])[0]}
|
||||
bmfs_to_shatter |= cur_under | pre_under
|
||||
if cur_under & pre_under and not prev.share_edge(cur):
|
||||
nedge = self.rfcontext.new_edge([prev, cur])
|
||||
if cur_faces & pre_faces and not cur.share_edge(prev):
|
||||
face = next(iter(cur_faces & pre_faces))
|
||||
try:
|
||||
face.split(prev, cur)
|
||||
except Exception as ex:
|
||||
print(f'Knife caught Exception while trying to split face {face} ({prev}-{cur})')
|
||||
print(ex)
|
||||
|
||||
if not cur.link_faces:
|
||||
unfaced_verts.append(cur)
|
||||
prev = cur
|
||||
pre_e = e
|
||||
pre_p = p
|
||||
|
||||
self.rfcontext.select(prev)
|
||||
|
||||
for bmf in bmfs_to_shatter:
|
||||
if bmf: bmf.shatter()
|
||||
|
||||
if (pre_p - self.actions.mouse).length <= self.rfcontext.drawing.scale(options['knife snap dist']):
|
||||
self.knife_start = None
|
||||
self.prep_move(
|
||||
bmverts_xys=([(prev, self.actions.mouse)] if prev else []),
|
||||
action_confirm=self.knife_actions['move'],
|
||||
)
|
||||
return 'move'
|
||||
|
||||
self.knife_start = self.actions.mouse
|
||||
|
||||
case _:
|
||||
assert False, f'Unhandled state {next_state}'
|
||||
|
||||
return
|
||||
|
||||
|
||||
def _get_crosses(self, p0, p1):
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
dist = self.rfcontext.drawing.scale(options['knife snap dist'])
|
||||
crosses = set()
|
||||
touched = set()
|
||||
#p0 = Point_to_Point2D(p0)
|
||||
#p1 = Point_to_Point2D(p1)
|
||||
v01 = Vec2D(p1 - p0)
|
||||
lv01 = max(v01.length, 0.00001)
|
||||
d01 = v01 / lv01
|
||||
def add(p, e):
|
||||
if e in touched: return
|
||||
p.freeze()
|
||||
touched.add(e)
|
||||
crosses.add((p, e, d01.dot(p - p0) / lv01))
|
||||
p0v = self.rfcontext.accel_nearest2D_vert(point=p0, max_dist=options['knife snap dist'])[0]
|
||||
if p0v and not p0v.link_edges:
|
||||
add(p0, p0v)
|
||||
for e in self.vis_edges:
|
||||
v0, v1 = e.verts
|
||||
c0, c1 = Point_to_Point2D(v0.co), Point_to_Point2D(v1.co)
|
||||
i = intersect2d_segment_segment(p0, p1, c0, c1)
|
||||
clc0 = closest2d_point_segment(c0, p0, p1)
|
||||
clc1 = closest2d_point_segment(c1, p0, p1)
|
||||
clp0 = closest2d_point_segment(p0, c0, c1)
|
||||
clp1 = closest2d_point_segment(p1, c0, c1)
|
||||
if (clc0 - c0).length <= dist: add(c0, v0)
|
||||
elif (clc1 - c1).length <= dist: add(c1, v1)
|
||||
elif (clp0 - p0).length <= dist: add(clp0, e)
|
||||
elif (clp1 - p1).length <= dist: add(clp1, e)
|
||||
elif i: add(Point2D(i), e)
|
||||
crosses = sorted(crosses, key=lambda cross: cross[2])
|
||||
return crosses
|
||||
|
||||
def draw_crosses(self, crosses):
|
||||
with Globals.drawing.draw(CC_2D_POINTS) as draw:
|
||||
for p,e,d in crosses:
|
||||
draw.color(themes['active'] if type(e) is RFVert else themes['new'])
|
||||
draw.vertex(p)
|
||||
|
||||
def draw_lines(self, coords, poly_alpha=0.2):
|
||||
line_color = themes['new']
|
||||
poly_color = [line_color[0], line_color[1], line_color[2], line_color[3] * poly_alpha]
|
||||
l = len(coords)
|
||||
coords = [self.rfcontext.Point_to_Point2D(co) for co in coords]
|
||||
if not all(coords): return
|
||||
|
||||
if l == 1:
|
||||
with Globals.drawing.draw(CC_2D_POINTS) as draw:
|
||||
draw.color(line_color)
|
||||
for c in coords:
|
||||
draw.vertex(c)
|
||||
|
||||
elif l == 2:
|
||||
with Globals.drawing.draw(CC_2D_LINES) as draw:
|
||||
draw.color(line_color)
|
||||
draw.vertex(coords[0])
|
||||
draw.vertex(coords[1])
|
||||
|
||||
else:
|
||||
with Globals.drawing.draw(CC_2D_LINE_LOOP) as draw:
|
||||
draw.color(line_color)
|
||||
for co in coords: draw.vertex(co)
|
||||
|
||||
with Globals.drawing.draw(CC_2D_TRIANGLE_FAN) as draw:
|
||||
draw.color(poly_color)
|
||||
draw.vertex(coords[0])
|
||||
for co1,co2 in iter_pairs(coords[1:], False):
|
||||
draw.vertex(co1)
|
||||
draw.vertex(co2)
|
||||
@@ -0,0 +1,27 @@
|
||||
<details id="knife-options">
|
||||
<summary id="knife-summary-label">Knife</summary>
|
||||
<div class="contents">
|
||||
<div class="collection">
|
||||
<h1>Automerge</h1>
|
||||
<div class="contents">
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['knife automerge']''')" title="If enabled, grabbed vertices automatically merged with nearby vertices">
|
||||
Enable Automerge
|
||||
</label>
|
||||
<div class="labeled-input-text">
|
||||
<label for="knife-merge-distance">Merge distance</label>
|
||||
<input id="knife-merge-distance" type="number" value="BoundInt( '''options['knife merge dist'] ''')" title="Pixel distance for merging and snapping">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Knife Options</h1>
|
||||
<div class="contents">
|
||||
<div class="labeled-input-text">
|
||||
<label for="knife-snap-distance">Snap distance</label>
|
||||
<input id="knife-snap-distance" type="number" value="BoundInt( '''options['knife snap dist'] ''')" title="Pixel distance for snapping">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@@ -0,0 +1,436 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import bpy
|
||||
import gpu
|
||||
import math
|
||||
import random
|
||||
import itertools
|
||||
|
||||
from ..rftool import RFTool
|
||||
from ..rfmesh.rfmesh import RFVert, RFEdge, RFFace
|
||||
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
|
||||
from ..rfwidgets.rfwidget_hidden import RFWidget_Hidden_Factory
|
||||
|
||||
from ...addon_common.common import gpustate
|
||||
from ...addon_common.common.maths import (
|
||||
Point, Vec, Normal, Direction,
|
||||
Point2D, Vec2D, Direction2D,
|
||||
clamp, Color, Plane,
|
||||
)
|
||||
from ...addon_common.common.debug import dprint
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.decorators import timed_call
|
||||
from ...addon_common.common.drawing import CC_2D_LINE_STRIP, CC_2D_LINE_LOOP, CC_DRAW, DrawCallbacks
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.timerhandler import StopwatchHandler
|
||||
from ...addon_common.common.utils import iter_pairs
|
||||
|
||||
from .loops_insert import Loops_Insert
|
||||
|
||||
from ...config.options import options, themes
|
||||
|
||||
|
||||
class Loops(RFTool, Loops_Insert):
|
||||
name = 'Loops'
|
||||
description = 'Edge loops creation, shifting, and deletion'
|
||||
icon = 'loops-icon.png'
|
||||
help = 'loops.md'
|
||||
shortcut = 'loops tool'
|
||||
quick_shortcut = 'loops quick'
|
||||
statusbar = '{{insert}} Insert edge loop\t{{smooth edge flow}} Smooth edge flow'
|
||||
|
||||
RFWidget_Default = RFWidget_Default_Factory.create()
|
||||
RFWidget_Move = RFWidget_Default_Factory.create(cursor='HAND')
|
||||
RFWidget_Crosshair = RFWidget_Default_Factory.create(cursor='CROSSHAIR')
|
||||
RFWidget_Hidden = RFWidget_Hidden_Factory.create()
|
||||
|
||||
@RFTool.on_init
|
||||
def init(self):
|
||||
self.rfwidgets = {
|
||||
'default': self.RFWidget_Default(self),
|
||||
'cut': self.RFWidget_Crosshair(self),
|
||||
'hover': self.RFWidget_Move(self),
|
||||
'hidden': self.RFWidget_Hidden(self),
|
||||
}
|
||||
self.rfwidget = None
|
||||
|
||||
def _fsm_in_main(self):
|
||||
# needed so main actions using Ctrl (ex: undo, redo, save) can still work
|
||||
return self._fsm.state in {'main', 'insert'}
|
||||
|
||||
@RFTool.on_reset
|
||||
def reset(self):
|
||||
self.nearest_edge = None
|
||||
self.set_next_state()
|
||||
self.hovering_edge = None
|
||||
self.hovering_sel_edge = None
|
||||
self.update_hover()
|
||||
self.quickswitch = False
|
||||
|
||||
def filter_edge_selection(self, bme, no_verts_select=True, ratio=0.33):
|
||||
if bme.select:
|
||||
# edge is already selected
|
||||
return True
|
||||
bmv0, bmv1 = bme.verts
|
||||
s0, s1 = bmv0.select, bmv1.select
|
||||
if s0 and s1:
|
||||
# both verts are selected, so return True
|
||||
return True
|
||||
if not s0 and not s1:
|
||||
if no_verts_select:
|
||||
# neither are selected, so return True by default
|
||||
return True
|
||||
else:
|
||||
# return True if none are selected; otherwise return False
|
||||
return self.rfcontext.none_selected()
|
||||
# if mouse is at least a ratio of the distance toward unselected vert, return True
|
||||
if s1: bmv0, bmv1 = bmv1, bmv0
|
||||
p = self.actions.mouse
|
||||
p0 = self.rfcontext.Point_to_Point2D(bmv0.co)
|
||||
p1 = self.rfcontext.Point_to_Point2D(bmv1.co)
|
||||
v01 = p1 - p0
|
||||
l01 = v01.length
|
||||
d01 = v01 / l01
|
||||
dot = d01.dot(p - p0)
|
||||
return dot / l01 > ratio
|
||||
|
||||
@RFTool.on_events('mouse move', 'target change', 'view change')
|
||||
@RFTool.not_while_navigating
|
||||
@FSM.onlyinstate('main')
|
||||
def update_hover(self):
|
||||
self.hovering_edge, _ = self.rfcontext.accel_nearest2D_edge(max_dist=options['action dist'])
|
||||
self.hovering_sel_edge, _ = self.rfcontext.accel_nearest2D_edge(max_dist=options['action dist'], selected_only=True)
|
||||
|
||||
@FSM.on_state('main')
|
||||
def main(self):
|
||||
# if self.actions.mousemove: return # ignore mouse moves
|
||||
|
||||
if self.hovering_edge and not self.hovering_edge.is_valid: self.hovering_edge = None
|
||||
if self.hovering_sel_edge and not self.hovering_sel_edge.is_valid: self.hovering_sel_edge = None
|
||||
|
||||
if self.actions.using_onlymods('insert'):
|
||||
return 'insert'
|
||||
|
||||
if self.hovering_edge:
|
||||
self.set_widget('hover')
|
||||
else:
|
||||
self.set_widget('default')
|
||||
|
||||
if self.handle_inactive_passthrough(): return
|
||||
|
||||
if self.actions.using('action'):
|
||||
self.hovering_edge, _ = self.rfcontext.accel_nearest2D_edge(max_dist=options['action dist'])
|
||||
if self.hovering_edge:
|
||||
#print(f'hovering edge {self.actions.using("action")} {self.hovering_edge} {self.hovering_sel_edge}')
|
||||
#print('acting!')
|
||||
self.rfcontext.undo_push('slide edge loop/strip')
|
||||
if not self.hovering_sel_edge:
|
||||
self.rfcontext.select_edge_loop(self.hovering_edge, supparts=False)
|
||||
self.set_next_state()
|
||||
self.prep_edit()
|
||||
if not self.edit_ok:
|
||||
self.rfcontext.undo_cancel()
|
||||
return
|
||||
self.move_done_pressed = None
|
||||
self.move_done_released = 'action'
|
||||
self.move_cancelled = 'cancel'
|
||||
return 'slide'
|
||||
|
||||
if self.actions.pressed('slide'):
|
||||
''' slide edge loop or strip between neighboring edges '''
|
||||
self.rfcontext.undo_push('slide edge loop/strip')
|
||||
self.prep_edit()
|
||||
if not self.edit_ok:
|
||||
self.rfcontext.undo_cancel()
|
||||
return
|
||||
self.move_done_pressed = 'confirm'
|
||||
self.move_done_released = None
|
||||
self.move_cancelled = 'cancel'
|
||||
return 'slide'
|
||||
|
||||
if self.actions.pressed({'select path add'}):
|
||||
return self.rfcontext.select_path(
|
||||
{'edge'},
|
||||
fn_filter_bmelem=self.filter_edge_selection,
|
||||
kwargs_select={'supparts': False},
|
||||
)
|
||||
|
||||
if self.actions.pressed({'select paint', 'select paint add'}, unpress=False):
|
||||
sel_only = self.actions.pressed('select paint')
|
||||
self.actions.unpress()
|
||||
return self.rfcontext.setup_smart_selection_painting(
|
||||
{'edge'},
|
||||
use_select_tool=True,
|
||||
selecting=not sel_only,
|
||||
deselect_all=sel_only,
|
||||
# fn_filter_bmelem=self.filter_edge_selection,
|
||||
kwargs_select={'supparts': False},
|
||||
kwargs_deselect={'subparts': False},
|
||||
)
|
||||
|
||||
if self.actions.pressed({'select smart', 'select smart add'}, unpress=False):
|
||||
sel_only = self.actions.pressed('select smart')
|
||||
self.actions.unpress()
|
||||
if not sel_only and not self.hovering_edge: return
|
||||
self.rfcontext.undo_push('select smart')
|
||||
if sel_only: self.rfcontext.deselect_all()
|
||||
if self.hovering_edge:
|
||||
self.rfcontext.select_edge_loop(self.hovering_edge, supparts=False, only=sel_only)
|
||||
return
|
||||
|
||||
if self.actions.pressed({'select single', 'select single add'}, unpress=False):
|
||||
sel_only = self.actions.pressed('select single')
|
||||
self.actions.unpress()
|
||||
if not sel_only and not self.hovering_edge: return
|
||||
self.rfcontext.undo_push('select')
|
||||
if sel_only: self.rfcontext.deselect_all()
|
||||
if not self.hovering_edge: return
|
||||
if self.hovering_edge.select: self.rfcontext.deselect(self.hovering_edge)
|
||||
else: self.rfcontext.select(self.hovering_edge, supparts=False, only=sel_only)
|
||||
return
|
||||
|
||||
|
||||
@FSM.on_state('selectadd/deselect')
|
||||
def selectadd_deselect(self):
|
||||
if not self.actions.using(['select single','select single add']):
|
||||
self.rfcontext.undo_push('deselect')
|
||||
edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=10)
|
||||
if edge and edge.select: self.rfcontext.deselect(edge)
|
||||
return 'main'
|
||||
delta = Vec2D(self.actions.mouse - self.mousedown)
|
||||
if delta.length > self.drawing.scale(5):
|
||||
self.rfcontext.undo_push('select add')
|
||||
return 'select'
|
||||
|
||||
@FSM.on_state('select')
|
||||
def select(self):
|
||||
if not self.actions.using(['select single','select single add']):
|
||||
return 'main'
|
||||
bme,_ = self.rfcontext.accel_nearest2D_edge(max_dist=10)
|
||||
if not bme or bme.select: return
|
||||
self.rfcontext.select(bme, supparts=False, only=False)
|
||||
|
||||
|
||||
def prep_edit(self):
|
||||
self.edit_ok = False
|
||||
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
|
||||
sel_verts = self.rfcontext.get_selected_verts()
|
||||
sel_edges = self.rfcontext.get_selected_edges()
|
||||
|
||||
if len(sel_verts) == 0 or len(sel_edges) == 0: return
|
||||
|
||||
if True:
|
||||
# use line perpendicular to average edge direction
|
||||
vis_verts = self.rfcontext.visible_verts(verts=sel_verts)
|
||||
vis_edges = self.rfcontext.visible_edges(verts=vis_verts, edges=sel_edges)
|
||||
edge_d = None
|
||||
for edge in vis_edges:
|
||||
v0, v1 = edge.verts
|
||||
p0, p1 = Point_to_Point2D(v0.co), Point_to_Point2D(v1.co)
|
||||
if not p0 or not p1: continue
|
||||
v = Direction2D(p1 - p0)
|
||||
if not edge_d:
|
||||
edge_d = v
|
||||
else:
|
||||
if edge_d.dot(v) < 0: edge_d -= v
|
||||
else: edge_d += v
|
||||
if not edge_d: return
|
||||
pts = [Point_to_Point2D(v.co) for v in vis_verts]
|
||||
pts = [pt for pt in pts if pt]
|
||||
if not pts: return
|
||||
self.slide_point = Point2D.average(pts)
|
||||
self.slide_direction = Direction2D((-edge_d.y, edge_d.x))
|
||||
else:
|
||||
# try to fit plane to data
|
||||
plane_o = Point.average(bmv.co for bmv in sel_verts)
|
||||
plane_n = Vec((0,0,0))
|
||||
for edge in sel_edges:
|
||||
v0, v1 = edge.verts
|
||||
en, ev = Normal(v0.normal + v1.normal), (v0.co - v1.co)
|
||||
perp = Direction(en.cross(ev))
|
||||
if plane_n.dot(perp) < 0: perp = -perp
|
||||
plane_n += perp
|
||||
plane_n = Normal(plane_n)
|
||||
o2d, on2d = Point_to_Point2D(plane_o), Point_to_Point2D(plane_o + plane_n)
|
||||
if not o2d or not on2d: return
|
||||
self.slide_direction = Direction2D(on2d - o2d)
|
||||
self.slide_point = o2d
|
||||
self.slide_vector = self.slide_direction * self.drawing.scale(40)
|
||||
|
||||
# slide_data holds info on left,right vectors for moving
|
||||
slide_data = {}
|
||||
working = set(sel_edges)
|
||||
while working:
|
||||
crawl_set = { (next(iter(working)), 1) }
|
||||
current_strip = set()
|
||||
while crawl_set:
|
||||
bme,side = crawl_set.pop()
|
||||
v0,v1 = bme.verts
|
||||
co0,co1 = v0.co,v1.co
|
||||
if bme not in working: continue
|
||||
working.discard(bme)
|
||||
|
||||
# add verts of edge if not already added
|
||||
for bmv in bme.verts:
|
||||
if bmv in slide_data: continue
|
||||
slide_data[bmv] = { 'left':[], 'orig':bmv.co, 'right':[], 'other':set(), 'flip': False }
|
||||
|
||||
# process edge
|
||||
bmfl,bmfr = bme.get_left_right_link_faces()
|
||||
bmefln = bmfl.neighbor_edges(bme) if bmfl else None
|
||||
bmefrn = bmfr.neighbor_edges(bme) if bmfr else None
|
||||
bmel0,bmel1 = bmefln or (None, None)
|
||||
bmer0,bmer1 = bmefrn or (None, None)
|
||||
bmvl0 = bmel0.other_vert(v0) if bmel0 else None
|
||||
bmvl1 = bmel1.other_vert(v1) if bmel1 else None
|
||||
bmvr0 = bmer1.other_vert(v0) if bmer1 else None
|
||||
bmvr1 = bmer0.other_vert(v1) if bmer0 else None
|
||||
col0 = bmvl0.co if bmvl0 else None
|
||||
col1 = bmvl1.co if bmvl1 else None
|
||||
cor0 = bmvr0.co if bmvr0 else None
|
||||
cor1 = bmvr1.co if bmvr1 else None
|
||||
|
||||
if col0 and cor0: pass # found left and right sides!
|
||||
elif col0: cor0 = co0 + (co0 - col0) # cor0 is missing, guess
|
||||
elif cor0: col0 = co0 + (co0 - cor0) # col0 is missing, guess
|
||||
else: continue # both col0 and cor0 are missing
|
||||
# instead of continuing, use edge perpendicular and length to guess at col0 and cor0
|
||||
if col1 and cor1: pass # found left and right sides!
|
||||
elif col1: cor1 = co1 + (co1 - col1) # cor1 is missing, guess
|
||||
elif cor1: col1 = co1 + (co1 - cor1) # col1 is missing, guess
|
||||
else: continue # both col1 and cor1 are missing
|
||||
# instead of continuing, use edge perpendicular and length to guess at col1 and cor1
|
||||
|
||||
current_strip |= { v0, v1 }
|
||||
|
||||
if side < 0:
|
||||
# edge direction is reversed, so swap left and right sides
|
||||
col0,cor0 = cor0,col0
|
||||
col1,cor1 = cor1,col1
|
||||
|
||||
if bmvl0 not in slide_data[v0]['other']:
|
||||
slide_data[v0]['left'].append(col0-co0)
|
||||
slide_data[v0]['other'].add(bmvl0)
|
||||
if bmvr0 not in slide_data[v0]['other']:
|
||||
slide_data[v0]['right'].append(co0-cor0)
|
||||
slide_data[v0]['other'].add(bmvr0)
|
||||
if bmvl1 not in slide_data[v1]['other']:
|
||||
slide_data[v1]['left'].append(col1-co1)
|
||||
slide_data[v1]['other'].add(bmvl1)
|
||||
if bmvr1 not in slide_data[v1]['other']:
|
||||
slide_data[v1]['right'].append(co1-cor1)
|
||||
slide_data[v1]['other'].add(bmvr1)
|
||||
|
||||
# crawl to neighboring edges in strip/loop
|
||||
bmes_next = { bme.get_next_edge_in_strip(bmv) for bmv in bme.verts }
|
||||
for bme_next in bmes_next:
|
||||
if bme_next not in working: continue # note: None will skipped, too
|
||||
v0_next,v1_next = bme_next.verts
|
||||
side_next = side * (1 if (v1 == v0_next or v0 == v1_next) else -1)
|
||||
crawl_set.add((bme_next, side_next))
|
||||
|
||||
# check if we need to flip the strip
|
||||
def fn(bmv, side):
|
||||
if not self.rfcontext.is_visible(bmv.co, occlusion_test_override=True): return
|
||||
p0 = Point_to_Point2D(bmv.co)
|
||||
if not p0: return
|
||||
m = 1 if side == 'left' else -1
|
||||
for v in slide_data[bmv][side]:
|
||||
p1 = Point_to_Point2D(bmv.co + v * m)
|
||||
if p1: yield (p1 - p0)
|
||||
l = [v for bmv in current_strip for v in fn(bmv, 'left')]
|
||||
r = [v for bmv in current_strip for v in fn(bmv, 'right')]
|
||||
wrong = [v for v in l if self.slide_direction.dot(v) < 0] + [v for v in r if self.slide_direction.dot(v) > 0]
|
||||
if len(wrong) > (len(l) + len(r)) / 2:
|
||||
for bmv in current_strip:
|
||||
slide_data[bmv]['flip'] = not slide_data[bmv]['flip']
|
||||
|
||||
# nearest_vert,_ = self.rfcontext.nearest2D_vert(verts=sel_verts)
|
||||
# if not nearest_vert: return
|
||||
# if nearest_vert not in slide_data: return
|
||||
|
||||
self.slide_data = slide_data
|
||||
self.mouse_down = self.actions.mouse
|
||||
self.percent_start = 0.0
|
||||
self.edit_ok = True
|
||||
|
||||
@FSM.on_state('slide', 'enter')
|
||||
def slide_enter(self):
|
||||
self.rfcontext.split_target_visualization_selected()
|
||||
self.rfcontext.set_accel_defer(True)
|
||||
self.set_widget('hidden' if options['hide cursor on tweak'] else 'hover')
|
||||
tag_redraw_all('entering slide')
|
||||
self.rfcontext.fast_update_timer.enable(True)
|
||||
|
||||
@FSM.on_state('slide')
|
||||
# @profiler.function
|
||||
def slide(self):
|
||||
released = self.actions.released
|
||||
if self.move_done_pressed and self.actions.pressed(self.move_done_pressed):
|
||||
return 'main'
|
||||
if self.move_done_released and self.actions.released(self.move_done_released, ignoremods=True):
|
||||
return 'main'
|
||||
if self.move_cancelled and self.actions.pressed('cancel'):
|
||||
self.rfcontext.undo_cancel()
|
||||
self.actions.unuse(self.move_done_released, ignoremods=True, ignoremulti=True)
|
||||
return 'main'
|
||||
|
||||
if not self.actions.mousemove_stop: return
|
||||
# # only update loop on timer events and when mouse has moved
|
||||
# if not self.actions.timer: return
|
||||
# if self.actions.mouse_prev == self.actions.mouse: return
|
||||
|
||||
mouse_delta = self.actions.mouse - self.mouse_down
|
||||
a,b = self.slide_vector, mouse_delta.project(self.slide_direction)
|
||||
percent = clamp(self.percent_start + a.dot(b) / a.dot(a), -1, 1)
|
||||
for bmv in self.slide_data.keys():
|
||||
mp = percent if not self.slide_data[bmv]['flip'] else -percent
|
||||
vecs = self.slide_data[bmv]['left' if mp > 0 else 'right']
|
||||
if len(vecs) == 0: continue
|
||||
co = self.slide_data[bmv]['orig']
|
||||
delta = sum((v * mp for v in vecs), Vec((0,0,0))) / len(vecs)
|
||||
bmv.co = co + delta
|
||||
self.rfcontext.snap_vert(bmv)
|
||||
|
||||
self.rfcontext.dirty()
|
||||
|
||||
@FSM.on_state('slide', 'exit')
|
||||
def slide_exit(self):
|
||||
self.rfcontext.fast_update_timer.enable(False)
|
||||
self.rfcontext.set_accel_defer(False)
|
||||
self.rfcontext.clear_split_target_visualization()
|
||||
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate('slide')
|
||||
def draw_postview_slide(self):
|
||||
gpustate.blend('ALPHA')
|
||||
Globals.drawing.draw2D_line(
|
||||
self.slide_point + self.slide_vector * 1000,
|
||||
self.slide_point - self.slide_vector * 1000,
|
||||
(0.1, 1.0, 1.0, 1.0), color1=(0.1, 1.0, 1.0, 0.0),
|
||||
width=2, stipple=[2,2],
|
||||
)
|
||||
@@ -0,0 +1,244 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import bpy
|
||||
import gpu
|
||||
import math
|
||||
import random
|
||||
import itertools
|
||||
|
||||
from ..rftool import RFTool
|
||||
from ..rfmesh.rfmesh import RFVert, RFEdge, RFFace
|
||||
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
|
||||
from ..rfwidgets.rfwidget_hidden import RFWidget_Hidden_Factory
|
||||
|
||||
from ...addon_common.common import gpustate
|
||||
from ...addon_common.common.maths import (
|
||||
Point, Vec, Normal, Direction,
|
||||
Point2D, Vec2D, Direction2D,
|
||||
clamp, Color, Plane,
|
||||
)
|
||||
from ...addon_common.common.debug import dprint
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.decorators import timed_call
|
||||
from ...addon_common.common.drawing import CC_2D_LINE_STRIP, CC_2D_LINE_LOOP, CC_DRAW, DrawCallbacks
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.timerhandler import StopwatchHandler
|
||||
from ...addon_common.common.utils import iter_pairs
|
||||
|
||||
from ...config.options import options, themes
|
||||
|
||||
|
||||
class Loops_Insert():
|
||||
@RFTool.on_quickswitch_start
|
||||
def quickswitch_start(self):
|
||||
self.quickswitch = True
|
||||
self._fsm.force_set_state('insert')
|
||||
|
||||
@FSM.on_state('insert', 'enter')
|
||||
def modal_previs_enter(self):
|
||||
self.set_widget('cut')
|
||||
self.rfcontext.fast_update_timer.enable(True)
|
||||
|
||||
if not self.quickswitch:
|
||||
self.insert_action = lambda: self.actions.pressed('insert')
|
||||
self.insert_done = lambda: not self.actions.using_onlymods('insert')
|
||||
else:
|
||||
self.insert_action = lambda: self.actions.pressed({'quick insert', 'confirm quick'})
|
||||
self.insert_done = lambda: self.actions.pressed('cancel')
|
||||
|
||||
|
||||
@FSM.on_state('insert')
|
||||
def modal_previs(self):
|
||||
if self.handle_inactive_passthrough():
|
||||
return
|
||||
|
||||
if self.insert_action() and self.nearest_edge:
|
||||
# insert edge loop / strip, select it, prep slide!
|
||||
return self.insert_edge_loop_strip()
|
||||
|
||||
if self.insert_done():
|
||||
return 'main'
|
||||
|
||||
@FSM.on_state('insert', 'exit')
|
||||
def modal_previs_exit(self):
|
||||
self.rfcontext.fast_update_timer.enable(False)
|
||||
|
||||
|
||||
@RFTool.on_events('mouse move', 'target change', 'view change')
|
||||
@RFTool.not_while_navigating
|
||||
@RFTool.once_per_frame
|
||||
@FSM.onlyinstate('insert')
|
||||
def set_next_state(self):
|
||||
if self.actions.mouse is None: return
|
||||
self.edges_ = None
|
||||
|
||||
self.nearest_edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['action dist'])
|
||||
|
||||
self.percent = 0
|
||||
self.edges = None
|
||||
|
||||
if not self.nearest_edge: return
|
||||
|
||||
self.edges,self.edge_loop = self.rfcontext.get_face_loop(self.nearest_edge)
|
||||
if not self.edges:
|
||||
# nearest, but no loop
|
||||
return
|
||||
vp0,vp1 = self.edges[0].verts
|
||||
cp0,cp1 = vp0.co,vp1.co
|
||||
def get(ep,ec):
|
||||
nonlocal cp0, cp1
|
||||
vc0,vc1 = ec.verts
|
||||
cc0,cc1 = vc0.co,vc1.co
|
||||
if (cp1-cp0).dot(cc1-cc0) < 0: cc0,cc1 = cc1,cc0
|
||||
cp0,cp1 = cc0,cc1
|
||||
return (ec,cc0,cc1)
|
||||
edge0 = self.edges[0]
|
||||
self.edges_ = [get(e0,e1) for e0,e1 in zip([self.edges[0]] + self.edges,self.edges)]
|
||||
c0,c1 = next(((c0,c1) for e,c0,c1 in self.edges_ if e == self.nearest_edge), (None,None))
|
||||
if c0 is None or c1 is None:
|
||||
r'''
|
||||
nearest_edge is not in list, because
|
||||
+-- this diamond quad causes problems!
|
||||
|
|
||||
V
|
||||
O-----O-----O
|
||||
| / \ |
|
||||
O---O O---O
|
||||
/ \ / \
|
||||
/ O \
|
||||
/ / \ \
|
||||
O---O O---O
|
||||
\ \ / /
|
||||
\ O /
|
||||
\ | /
|
||||
\ | /
|
||||
\|/
|
||||
O
|
||||
'''
|
||||
self.edges = None
|
||||
self.edges_ = None
|
||||
return
|
||||
c0,c1 = self.rfcontext.Point_to_Point2D(c0),self.rfcontext.Point_to_Point2D(c1)
|
||||
a,b = c1 - c0, self.actions.mouse - c0
|
||||
adota = a.dot(a)
|
||||
if adota <= 0.0000001:
|
||||
self.percent = 0
|
||||
self.edges = None
|
||||
self.edges_ = None
|
||||
return
|
||||
self.percent = a.dot(b) / adota
|
||||
|
||||
tag_redraw_all('Loops next state set')
|
||||
|
||||
|
||||
|
||||
def insert_edge_loop_strip(self):
|
||||
if not self.edges_: return
|
||||
|
||||
self.rfcontext.undo_push(f'insert edge {"loop" if self.edge_loop else "strip"}')
|
||||
|
||||
# if quad strip is a loop, then need to connect first and last new verts
|
||||
is_looped = self.rfcontext.is_quadstrip_looped(self.nearest_edge)
|
||||
|
||||
def split_face(v0, v1):
|
||||
nonlocal new_edges
|
||||
f0 = next(iter(v0.shared_faces(v1)), None)
|
||||
if not f0:
|
||||
self.rfcontext.alert_user('Something unexpected happened', level='warning')
|
||||
self.rfcontext.undo_cancel()
|
||||
return
|
||||
f1 = f0.split(v0, v1)
|
||||
new_edges.append(f0.shared_edge(f1))
|
||||
|
||||
# create new verts by splitting all the edges
|
||||
new_verts, new_edges = [],[]
|
||||
|
||||
def compute_percent():
|
||||
v0,v1 = self.nearest_edge.verts
|
||||
c0,c1 = self.rfcontext.Point_to_Point2D(v0.co),self.rfcontext.Point_to_Point2D(v1.co)
|
||||
a,b = c1 - c0, self.actions.mouse - c0
|
||||
adota = a.dot(a)
|
||||
if adota <= 0.0000001: return 0
|
||||
return a.dot(b) / adota;
|
||||
percent = compute_percent()
|
||||
|
||||
for e,flipped in self.rfcontext.iter_quadstrip(self.nearest_edge):
|
||||
bmv0,bmv1 = e.verts
|
||||
if flipped: bmv0,bmv1 = bmv1,bmv0
|
||||
ne,nv = e.split()
|
||||
nv.co = bmv0.co + (bmv1.co - bmv0.co) * percent
|
||||
self.rfcontext.snap_vert(nv)
|
||||
if new_verts: split_face(new_verts[-1], nv)
|
||||
new_verts.append(nv)
|
||||
|
||||
# connecting first and last new verts if quad strip is looped
|
||||
if is_looped and len(new_verts) > 2: split_face(new_verts[-1], new_verts[0])
|
||||
|
||||
self.rfcontext.dirty()
|
||||
self.rfcontext.select(new_edges)
|
||||
|
||||
self.prep_edit()
|
||||
if not self.edit_ok:
|
||||
self.rfcontext.undo_cancel()
|
||||
return
|
||||
self.move_done_pressed = None
|
||||
self.move_done_released = 'insert'
|
||||
self.move_cancelled = 'cancel'
|
||||
self.rfcontext.undo_push('slide edge loop/strip')
|
||||
return 'slide'
|
||||
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@RFTool.not_while_navigating
|
||||
@FSM.onlyinstate('insert')
|
||||
def draw_postview(self):
|
||||
if not self.nearest_edge: return
|
||||
|
||||
# draw new edge strip/loop
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
|
||||
def draw(color):
|
||||
if not self.edges_: return
|
||||
if self.edge_loop:
|
||||
with Globals.drawing.draw(CC_2D_LINE_LOOP) as draw:
|
||||
draw.color(color)
|
||||
for _,c0,c1 in self.edges_:
|
||||
c = c0 + (c1 - c0) * self.percent
|
||||
draw.vertex(Point_to_Point2D(c))
|
||||
else:
|
||||
with Globals.drawing.draw(CC_2D_LINE_STRIP) as draw:
|
||||
draw.color(color)
|
||||
for _,c0,c1 in self.edges_:
|
||||
c = c0 + (c1 - c0) * self.percent
|
||||
draw.vertex(Point_to_Point2D(c))
|
||||
|
||||
CC_DRAW.stipple(pattern=[4,4])
|
||||
CC_DRAW.point_size(5)
|
||||
CC_DRAW.line_width(2)
|
||||
|
||||
gpustate.blend('ALPHA')
|
||||
gpustate.depth_mask(True)
|
||||
gpustate.depth_test('LESS_EQUAL')
|
||||
draw(themes['new'])
|
||||
|
||||
@@ -0,0 +1,791 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import os
|
||||
import math
|
||||
from itertools import chain
|
||||
|
||||
from ..rftool import RFTool
|
||||
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
|
||||
from ..rfwidgets.rfwidget_hidden import RFWidget_Hidden_Factory
|
||||
|
||||
from ...addon_common.common.drawing import (
|
||||
CC_DRAW,
|
||||
CC_2D_POINTS,
|
||||
CC_2D_LINES, CC_2D_LINE_LOOP,
|
||||
CC_2D_TRIANGLES, CC_2D_TRIANGLE_FAN,
|
||||
)
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.maths import (
|
||||
Point, Vec, Direction,
|
||||
Point2D, Vec2D,
|
||||
mid,
|
||||
)
|
||||
from ...addon_common.common import gpustate
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.utils import iter_pairs
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.boundvar import BoundInt
|
||||
from ...addon_common.common.drawing import DrawCallbacks
|
||||
|
||||
from ...config.options import options, themes, visualization
|
||||
|
||||
class Patches(RFTool):
|
||||
name = 'Patches'
|
||||
description = 'Fill holes in your topology'
|
||||
icon = 'patches-icon.png'
|
||||
help = 'patches.md'
|
||||
shortcut = 'patches tool'
|
||||
statusbar = '{{action alt1}} Toggle vertex as a corner\t{{increase count}} Increase segments\t{{decrease count}} Decrease Segments\t{{fill}} Create patch'
|
||||
ui_config = 'patches_options.html'
|
||||
|
||||
RFWidget_Default = RFWidget_Default_Factory.create()
|
||||
RFWidget_Move = RFWidget_Default_Factory.create(cursor='HAND')
|
||||
RFWidget_Hidden = RFWidget_Hidden_Factory.create()
|
||||
|
||||
@RFTool.on_init
|
||||
def init(self):
|
||||
self.rfwidgets = {
|
||||
'default': self.RFWidget_Default(self),
|
||||
'hover': self.RFWidget_Move(self),
|
||||
'hidden': self.RFWidget_Hidden(self),
|
||||
}
|
||||
self.rfwidget = None
|
||||
self.corners = {}
|
||||
self.crosses = None
|
||||
self._var_angle = BoundInt('''options['patches angle']''', min_value=0, max_value=180)
|
||||
self._var_crosses = BoundInt('''self.var_crosses''', min_value=1, max_value=500)
|
||||
|
||||
@RFTool.on_reset
|
||||
def reset(self):
|
||||
self.defer_recomputing = False
|
||||
|
||||
@property
|
||||
def var_crosses(self):
|
||||
if self.crosses is None: return 1
|
||||
return self.crosses - 1
|
||||
@var_crosses.setter
|
||||
def var_crosses(self, v):
|
||||
nv = max(1, int(v)+1)
|
||||
if self.crosses == nv: return
|
||||
self.crosses = nv
|
||||
self._recompute()
|
||||
|
||||
def update_ui(self):
|
||||
self._var_crosses.disabled = (self.crosses is None)
|
||||
|
||||
def filter_edge_selection(self, bme, no_verts_select=True, ratio=0.33):
|
||||
if bme.select:
|
||||
# edge is already selected
|
||||
return True
|
||||
bmv0, bmv1 = bme.verts
|
||||
s0, s1 = bmv0.select, bmv1.select
|
||||
if s0 and s1:
|
||||
# both verts are selected, so return True
|
||||
return True
|
||||
if not s0 and not s1:
|
||||
if no_verts_select:
|
||||
# neither are selected, so return True by default
|
||||
return True
|
||||
else:
|
||||
# return True if none are selected; otherwise return False
|
||||
return self.rfcontext.none_selected()
|
||||
# if mouse is at least a ratio of the distance toward unselected vert, return True
|
||||
if s1: bmv0, bmv1 = bmv1, bmv0
|
||||
p = self.actions.mouse
|
||||
p0 = self.rfcontext.Point_to_Point2D(bmv0.co)
|
||||
p1 = self.rfcontext.Point_to_Point2D(bmv1.co)
|
||||
v01 = p1 - p0
|
||||
l01 = v01.length
|
||||
d01 = v01 / l01
|
||||
dot = d01.dot(p - p0)
|
||||
return dot / l01 > ratio
|
||||
|
||||
@RFTool.on_reset
|
||||
@RFTool.on_target_change
|
||||
def update(self):
|
||||
if self.defer_recomputing: return
|
||||
self.rfcontext.get_accel_visible()
|
||||
self.crosses = None
|
||||
self._recompute()
|
||||
self.update_ui()
|
||||
|
||||
@FSM.on_state('main')
|
||||
def main(self):
|
||||
self.hovering_sel_edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['action dist'], selected_only=True)
|
||||
self.hovering_sel_face,_ = self.rfcontext.accel_nearest2D_face(max_dist=options['action dist'], selected_only=True)
|
||||
|
||||
if self.hovering_sel_edge or self.hovering_sel_face:
|
||||
self.set_widget('hover')
|
||||
else:
|
||||
self.set_widget('default')
|
||||
|
||||
if self.handle_inactive_passthrough(): return
|
||||
|
||||
if self.hovering_sel_edge or self.hovering_sel_face:
|
||||
if self.actions.pressed('action'):
|
||||
self.move_done_pressed = None
|
||||
self.move_done_released = 'action'
|
||||
self.move_cancelled = 'cancel'
|
||||
return 'move'
|
||||
|
||||
if self.rfcontext.actions.pressed('action alt1'):
|
||||
vert,_ = self.rfcontext.accel_nearest2D_vert(max_dist=10)
|
||||
if not vert or not vert.select: return
|
||||
if vert in self.shapes['corners']:
|
||||
self.corners[vert] = False
|
||||
else:
|
||||
self.corners[vert] = not self.corners.get(vert, False)
|
||||
self.update()
|
||||
return
|
||||
|
||||
if self.rfcontext.actions.pressed('fill'):
|
||||
self.fill_patch()
|
||||
return
|
||||
|
||||
if self.rfcontext.actions.pressed('grab'):
|
||||
self.move_done_pressed = 'confirm'
|
||||
self.move_done_released = None
|
||||
self.move_cancelled = 'cancel'
|
||||
return 'move'
|
||||
|
||||
if self.rfcontext.actions.pressed('increase count'):
|
||||
if self.crosses is not None:
|
||||
self.crosses += 1
|
||||
self._recompute()
|
||||
|
||||
if self.rfcontext.actions.pressed('decrease count'):
|
||||
if self.crosses is not None and self.crosses > 2:
|
||||
self.crosses -= 1
|
||||
self._recompute()
|
||||
|
||||
if self.actions.pressed({'select path add'}):
|
||||
return self.rfcontext.select_path(
|
||||
{'edge'},
|
||||
fn_filter_bmelem=self.filter_edge_selection,
|
||||
kwargs_select={'supparts': False},
|
||||
)
|
||||
|
||||
if self.actions.pressed({'select paint', 'select paint add'}, unpress=False):
|
||||
sel_only = self.actions.pressed('select paint')
|
||||
self.actions.unpress()
|
||||
return self.rfcontext.setup_smart_selection_painting(
|
||||
{'edge'},
|
||||
use_select_tool=True,
|
||||
selecting=not sel_only,
|
||||
deselect_all=sel_only,
|
||||
# fn_filter_bmelem=self.filter_edge_selection,
|
||||
kwargs_select={'supparts': False},
|
||||
kwargs_deselect={'subparts': False},
|
||||
)
|
||||
|
||||
if self.actions.pressed({'select single', 'select single add'}, unpress=False):
|
||||
sel_only = self.actions.pressed('select single')
|
||||
hovering_edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['action dist'])
|
||||
if not sel_only and not hovering_edge: return
|
||||
self.rfcontext.undo_push('select')
|
||||
if sel_only: self.rfcontext.deselect_all()
|
||||
if not hovering_edge: return
|
||||
if sel_only or hovering_edge.select == False:
|
||||
self.rfcontext.select(hovering_edge, supparts=False, only=False)
|
||||
else:
|
||||
self.rfcontext.deselect(hovering_edge)
|
||||
return
|
||||
|
||||
if self.rfcontext.actions.pressed({'select smart', 'select smart add'}, unpress=False):
|
||||
sel_only = self.rfcontext.actions.pressed('select smart')
|
||||
self.rfcontext.actions.unpress()
|
||||
|
||||
self.rfcontext.undo_push('select smart')
|
||||
selectable_edges = [e for e in self.rfcontext.visible_edges() if len(e.link_faces) < 2]
|
||||
edge,_ = self.rfcontext.nearest2D_edge(edges=selectable_edges, max_dist=options['action dist'])
|
||||
if not edge: return
|
||||
#self.rfcontext.select_inner_edge_loop(edge, supparts=False, only=sel_only)
|
||||
self.rfcontext.select_edge_loop(edge, supparts=False, only=sel_only)
|
||||
|
||||
@FSM.on_state('move', 'enter')
|
||||
def move_enter(self):
|
||||
self.sel_verts = self.rfcontext.get_selected_verts()
|
||||
self.vis_accel = self.rfcontext.get_accel_visible()
|
||||
self.vis_verts = self.rfcontext.accel_vis_verts
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
|
||||
self.bmverts = [(bmv, Point_to_Point2D(bmv.co)) for bmv in self.sel_verts]
|
||||
self.vis_bmverts = [(bmv, Point_to_Point2D(bmv.co)) for bmv in self.vis_verts if bmv and bmv not in self.sel_verts]
|
||||
self.mousedown = self.rfcontext.actions.mouse
|
||||
self.defer_recomputing = True
|
||||
|
||||
self.rfcontext.undo_push('move grabbed')
|
||||
|
||||
self.rfcontext.set_accel_defer(True)
|
||||
|
||||
self._timer = self.actions.start_timer(120)
|
||||
|
||||
if options['hide cursor on tweak']: self.set_widget('hidden')
|
||||
|
||||
@FSM.on_state('move')
|
||||
def move_main(self):
|
||||
released = self.rfcontext.actions.released
|
||||
if self.move_done_pressed and self.rfcontext.actions.pressed(self.move_done_pressed):
|
||||
self.defer_recomputing = False
|
||||
self.update()
|
||||
return 'main'
|
||||
if self.actions.released(self.move_done_released, ignoredrag=True):
|
||||
self.defer_recomputing = False
|
||||
self.update()
|
||||
return 'main'
|
||||
if self.move_cancelled and self.rfcontext.actions.pressed('cancel'):
|
||||
self.defer_recomputing = False
|
||||
self.actions.unuse(self.move_done_released, ignoremods=True, ignoremulti=True)
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
|
||||
if not self.actions.mousemove_stop: return
|
||||
# if not self.rfcontext.actions.timer: return
|
||||
# if self.actions.mouse_prev == self.actions.mouse: return
|
||||
# # if not self.actions.mousemove: return
|
||||
|
||||
delta = Vec2D(self.actions.mouse - self.mousedown)
|
||||
set2D_vert = self.rfcontext.set2D_vert
|
||||
for bmv,xy in self.bmverts:
|
||||
xy_updated = xy + delta
|
||||
set2D_vert(bmv, xy_updated)
|
||||
# # check if xy_updated is "close" to any visible verts (in image plane)
|
||||
# # if so, snap xy_updated to vert position (in image plane)
|
||||
# if options['polypen automerge']:
|
||||
# for bmv1,xy1 in self.vis_bmverts:
|
||||
# if (xy_updated - xy1).length < self.rfcontext.drawing.scale(10):
|
||||
# set2D_vert(bmv, xy1)
|
||||
# break
|
||||
# else:
|
||||
# set2D_vert(bmv, xy_updated)
|
||||
# else:
|
||||
# set2D_vert(bmv, xy_updated)
|
||||
self.rfcontext.update_verts_faces(v for v,_ in self.bmverts)
|
||||
|
||||
self.rfcontext.dirty()
|
||||
|
||||
@FSM.on_state('move', 'exit')
|
||||
def move_exit(self):
|
||||
self._timer.done()
|
||||
self.rfcontext.set_accel_defer(False)
|
||||
|
||||
@RFTool.dirty_when_done
|
||||
def fill_patch(self):
|
||||
if not self.previz: return
|
||||
|
||||
new_vert = self.rfcontext.new_vert_point
|
||||
new_face = self.rfcontext.new_face
|
||||
|
||||
self.rfcontext.undo_push('fill')
|
||||
for previz in self.previz:
|
||||
verts,faces = previz['verts'],previz['faces']
|
||||
verts = [(new_vert(v) if type(v) is Point else v) for v in verts]
|
||||
for face in faces: new_face([verts[iv] for iv in face])
|
||||
|
||||
self.update()
|
||||
|
||||
|
||||
|
||||
def draw_previz(self, previz, poly_alpha=0.2):
|
||||
point_to_point2D = self.rfcontext.Point_to_Point2D
|
||||
line_color = themes['new']
|
||||
poly_color = [line_color[0], line_color[1], line_color[2], line_color[3] * poly_alpha]
|
||||
|
||||
verts = [point_to_point2D(v if type(v) is Point else (v.co if v.is_valid else None)) for v in previz['verts']]
|
||||
|
||||
with Globals.drawing.draw(CC_2D_LINES) as draw:
|
||||
draw.color(line_color)
|
||||
for i0,i1 in previz['edges']:
|
||||
v0,v1 = verts[i0],verts[i1]
|
||||
if v0 and v1:
|
||||
draw.vertex(v0)
|
||||
draw.vertex(v1)
|
||||
|
||||
with Globals.drawing.draw(CC_2D_TRIANGLES) as draw:
|
||||
draw.color(poly_color)
|
||||
for f in previz['faces']:
|
||||
coords = [verts[i] for i in f]
|
||||
if all(coords):
|
||||
co0 = coords[0]
|
||||
for i in range(1, len(coords)-1):
|
||||
draw.vertex(co0)
|
||||
draw.vertex(coords[i])
|
||||
draw.vertex(coords[i+1])
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate('main')
|
||||
def draw_postpixel(self):
|
||||
point_to_point2D = self.rfcontext.Point_to_Point2D
|
||||
self.rfcontext.drawing.set_font_size(12)
|
||||
|
||||
def get_pos(strips):
|
||||
#xy = max((point_to_point2D(bmv.co) for strip in strips for bme in strip for bmv in bme.verts), key=lambda xy:xy.y+xy.x/2)
|
||||
bmvs = [bmv for strip in strips for bme in strip for bmv in bme.verts]
|
||||
vs = [point_to_point2D(bmv.co) for bmv in bmvs]
|
||||
vs = [Vec2D(v) for v in vs if v]
|
||||
if not vs: return None
|
||||
xy = sum(vs, Vec2D((0,0))) / len(vs)
|
||||
return xy+Vec2D((2,14))
|
||||
def text_draw2D(s, strips):
|
||||
if not strips: return
|
||||
xy = get_pos(strips)
|
||||
if not xy: return
|
||||
self.rfcontext.drawing.text_draw2D(s, xy, color=(1,1,0,1), dropshadow=(0,0,0,0.5))
|
||||
|
||||
for rect_strips in self.shapes['rect']:
|
||||
c0,c1,c2,c3 = map(len, rect_strips)
|
||||
if c0==c2 and c1==c3:
|
||||
s = 'rect: %dx%d' % (c0,c1)
|
||||
text_draw2D(s, rect_strips)
|
||||
else:
|
||||
for strip in rect_strips:
|
||||
s = 'bad rect: %d' % len(strip)
|
||||
text_draw2D(s, [strip])
|
||||
|
||||
for I_strips in self.shapes['I']:
|
||||
c = len(I_strips[0])
|
||||
s = 'I: %d' % (c,)
|
||||
text_draw2D(s, I_strips)
|
||||
for L_strips in self.shapes['L']:
|
||||
c0,c1 = map(len, L_strips)
|
||||
s = 'L: %dx%d' % (c0,c1)
|
||||
text_draw2D(s, L_strips)
|
||||
for C_strips in self.shapes['C']:
|
||||
c0,c1,c2 = map(len, C_strips)
|
||||
if c0==c2:
|
||||
s = 'C: %dx%d' % (c0,c1)
|
||||
text_draw2D(s, C_strips)
|
||||
else:
|
||||
for strip in C_strips:
|
||||
s = 'bad C: %d' % len(strip)
|
||||
text_draw2D(s, [strip])
|
||||
|
||||
gpustate.blend('ALPHA')
|
||||
CC_DRAW.stipple(pattern=[4,4])
|
||||
CC_DRAW.point_size(4)
|
||||
CC_DRAW.line_width(2)
|
||||
|
||||
for previz in self.previz: self.draw_previz(previz)
|
||||
|
||||
gpustate.blend('ALPHA')
|
||||
CC_DRAW.stipple()
|
||||
CC_DRAW.point_size(visualization['point size highlight'])
|
||||
with Globals.drawing.draw(CC_2D_POINTS) as draw:
|
||||
draw.color(visualization['point color highlight'])
|
||||
for corner in self.shapes['corners']:
|
||||
p = point_to_point2D(corner.co)
|
||||
if p: draw.vertex(p)
|
||||
|
||||
|
||||
|
||||
def _clear_shapes(self):
|
||||
self.shapes = {
|
||||
'O': [], # special loop
|
||||
'eye': [], # loops
|
||||
'tri': [],
|
||||
'rect': [],
|
||||
'ngon': [],
|
||||
'C': [], # strings
|
||||
'L': [],
|
||||
'I': [],
|
||||
'else': [],
|
||||
'corners': [],
|
||||
}
|
||||
self.previz = []
|
||||
|
||||
def _recompute(self):
|
||||
min_angle = options['patches angle']
|
||||
def nearest_sources_Point(p):
|
||||
p,n,i,d = self.rfcontext.nearest_sources_Point(p)
|
||||
return self.rfcontext.clamp_point_to_symmetry(p)
|
||||
|
||||
self._clear_shapes()
|
||||
# remove old corners that are no longer valid or selected
|
||||
self.corners = {v:corner for (v, corner) in self.corners.items() if v.is_valid and v.select}
|
||||
|
||||
##############################################
|
||||
# find edges that could be part of a strip
|
||||
edges = set(e for e in self.rfcontext.get_selected_edges() if len(e.link_faces) < 2)
|
||||
|
||||
|
||||
###################
|
||||
# find strips
|
||||
remaining_edges = set(edges)
|
||||
strips = []
|
||||
neighbors = { e:[] for e in edges }
|
||||
while remaining_edges:
|
||||
strip = set()
|
||||
working = { next(iter(remaining_edges)) }
|
||||
while working:
|
||||
edge = working.pop()
|
||||
strip.add(edge)
|
||||
remaining_edges.remove(edge)
|
||||
v0,v1 = edge.verts
|
||||
for e in chain(v0.link_edges, v1.link_edges):
|
||||
if e not in remaining_edges: continue
|
||||
bmv1 = edge.shared_vert(e)
|
||||
if self.corners.get(bmv1, False): continue
|
||||
bmv0 = edge.other_vert(bmv1)
|
||||
bmv2 = e.other_vert(bmv1)
|
||||
d10 = Direction(bmv0.co-bmv1.co)
|
||||
d12 = Direction(bmv2.co-bmv1.co)
|
||||
angle = math.degrees(math.acos(mid(-1,1,d10.dot(d12))))
|
||||
if self.corners.get(bmv1, True) and angle < min_angle: continue
|
||||
neighbors[edge].append(e)
|
||||
neighbors[e].append(edge)
|
||||
working.add(e)
|
||||
strips += [strip]
|
||||
|
||||
|
||||
##############################################
|
||||
# order strips to find corners and O-shapes
|
||||
nstrips = []
|
||||
corners = dict()
|
||||
for edges in strips:
|
||||
if len(edges) == 1:
|
||||
# single edge in strip
|
||||
edge = next(iter(edges))
|
||||
strip = [edge]
|
||||
v0,v1 = edge.verts
|
||||
nstrips.append(strip)
|
||||
corners[v0] = corners.get(v0, []) + [strip]
|
||||
corners[v1] = corners.get(v1, []) + [strip]
|
||||
continue
|
||||
end_edges = [edge for edge in edges if len(neighbors[edge])==1]
|
||||
if not end_edges:
|
||||
# could not find corners: O-shaped!
|
||||
strip = [next(iter(edges))]
|
||||
strip.append(next(iter(neighbors[strip[0]])))
|
||||
remaining_edges = set(edges) - set(strip)
|
||||
isbad = False
|
||||
while remaining_edges:
|
||||
next_edges = [edge for edge in neighbors[strip[-1]] if edge in remaining_edges]
|
||||
if len(next_edges) != 1:
|
||||
# unexpected number of edges found!
|
||||
isbad = True
|
||||
break
|
||||
strip.append(next_edges[0])
|
||||
remaining_edges.remove(next_edges[0])
|
||||
if isbad: continue
|
||||
self.shapes['O'].append(strip)
|
||||
continue
|
||||
strip = [end_edges[0]]
|
||||
remaining_edges = set(edges) - set(strip)
|
||||
isbad = False
|
||||
while remaining_edges:
|
||||
next_edges = [edge for edge in neighbors[strip[-1]] if edge in remaining_edges]
|
||||
if len(next_edges) != 1:
|
||||
# unexpected number of edges found
|
||||
# see GitHub issue #481 (https://github.com/CGCookie/retopoflow/issues/481)
|
||||
isbad = True
|
||||
break
|
||||
strip.append(next_edges[0])
|
||||
remaining_edges.remove(next_edges[0])
|
||||
if isbad: continue
|
||||
v0 = strip[0].other_vert(strip[0].shared_vert(strip[1]))
|
||||
v1 = strip[-1].other_vert(strip[-1].shared_vert(strip[-2]))
|
||||
corners[v0] = corners.get(v0, []) + [strip]
|
||||
corners[v1] = corners.get(v1, []) + [strip]
|
||||
nstrips.append(strip)
|
||||
strips = nstrips
|
||||
|
||||
|
||||
##################################################################
|
||||
# find all strings (I,L,C,else) and loops (cat,tri,rect,ngon)
|
||||
# note: all corner verts with one strip are *not* in a loop
|
||||
|
||||
# ignore corners with 3+ strips
|
||||
ignore_corners = {c for c in corners if len(corners[c]) > 2}
|
||||
|
||||
def align_strips(strips):
|
||||
''' make sure that the edges at the end of adjacent strips share a vertex '''
|
||||
if len(strips) == 1: return strips
|
||||
strip0,strip1 = strips[:2]
|
||||
if strip0[0].share_vert(strip1[0]) or strip0[0].share_vert(strip1[-1]): strip0.reverse()
|
||||
assert strip0[-1].share_vert(strip1[0]) or strip0[-1].share_vert(strip1[-1])
|
||||
for strip0,strip1 in zip(strips[:-1],strips[1:]):
|
||||
if strip1[-1].share_vert(strip0[-1]): strip1.reverse()
|
||||
assert strip1[0].share_vert(strip0[-1])
|
||||
return strips
|
||||
|
||||
remaining_corners = set(corners.keys())
|
||||
string_corners = set()
|
||||
loop_corners = set()
|
||||
strings_strips = list()
|
||||
loops_strips = list(self.shapes['O'])
|
||||
|
||||
# find strips
|
||||
while remaining_corners:
|
||||
c = next((c for c in remaining_corners if len(corners[c]) == 1), None)
|
||||
if not c: break
|
||||
remaining_corners.remove(c)
|
||||
string_corners.add(c)
|
||||
string_strips = [corners[c][0]]
|
||||
ignore = c in ignore_corners
|
||||
while True:
|
||||
s = string_strips[-1]
|
||||
c = next((c for c in remaining_corners if s in corners[c]), None)
|
||||
if not c: break
|
||||
ignore |= c in ignore_corners
|
||||
remaining_corners.remove(c)
|
||||
string_corners.add(c)
|
||||
if len(corners[c]) != 2: break
|
||||
ns = next(ns for ns in corners[c] if ns != s)
|
||||
string_strips.append(ns)
|
||||
string_strips = align_strips(string_strips)
|
||||
if ignore: continue
|
||||
strings_strips.append(string_strips)
|
||||
if len(string_strips) == 1:
|
||||
self.shapes['I'].append(string_strips)
|
||||
elif len(string_strips) == 2:
|
||||
self.shapes['L'].append(string_strips)
|
||||
elif len(string_strips) == 3:
|
||||
self.shapes['C'].append(string_strips)
|
||||
else:
|
||||
self.shapes['else'].append(string_strips)
|
||||
|
||||
# find loops
|
||||
while remaining_corners:
|
||||
c = next(iter(remaining_corners))
|
||||
remaining_corners.remove(c)
|
||||
loop_corners.add(c)
|
||||
loop_strips = [corners[c][0]]
|
||||
ignore = c in ignore_corners
|
||||
while True:
|
||||
s = loop_strips[-1]
|
||||
c = next((c for c in remaining_corners if s in corners[c]), None)
|
||||
if not c: break
|
||||
ignore |= c in ignore_corners
|
||||
remaining_corners.remove(c)
|
||||
loop_corners.add(c)
|
||||
ns = next((ns for ns in corners[c] if ns != s), None)
|
||||
if not ns: break
|
||||
loop_strips.append(ns)
|
||||
loop_strips = align_strips(loop_strips)
|
||||
if ignore: continue
|
||||
# make sure loop is actually closed
|
||||
s0,s1 = loop_strips[0],loop_strips[-1]
|
||||
shared_verts = sum(1 if e0.share_vert(e1) else 0 for e0 in s0 for e1 in s1)
|
||||
if len(loop_strips) == 2 and shared_verts != 2: continue
|
||||
if len(loop_strips) > 2 and shared_verts != 1: continue
|
||||
loops_strips.append(loop_strips)
|
||||
if len(loop_strips) == 2:
|
||||
self.shapes['eye'].append(loop_strips)
|
||||
elif len(loop_strips) == 3:
|
||||
self.shapes['tri'].append(loop_strips)
|
||||
elif len(loop_strips) == 4:
|
||||
self.shapes['rect'].append(loop_strips)
|
||||
else:
|
||||
self.shapes['ngon'].append(loop_strips)
|
||||
|
||||
self.shapes['corners'] = (string_corners | loop_corners)
|
||||
|
||||
###################
|
||||
# generate previz
|
||||
|
||||
def get_verts(strip, rev=False):
|
||||
if len(strip) == 1: return list(strip[0].verts)
|
||||
bmvs = [strip[0].nonshared_vert(strip[1])]
|
||||
bmvs += [e0.shared_vert(e1) for e0,e1 in zip(strip[:-1], strip[1:])]
|
||||
bmvs += [strip[-1].nonshared_vert(strip[-2])]
|
||||
if rev: bmvs.reverse()
|
||||
return bmvs
|
||||
|
||||
# rect
|
||||
for shape in self.shapes['rect']:
|
||||
s0,s1,s2,s3 = shape
|
||||
if len(s0) != len(s2) or len(s1) != len(s3): continue # invalid rect
|
||||
sv0,sv1,sv2,sv3 = get_verts(s0),get_verts(s1),get_verts(s2,True),get_verts(s3,True)
|
||||
l0,l1 = len(sv0),len(sv1)
|
||||
|
||||
# make sure each strip is in the correct order
|
||||
if sv0[-1] not in sv1: sv0.reverse()
|
||||
if sv1[-1] not in sv2: sv1.reverse()
|
||||
if sv2[-1] not in sv1: sv2.reverse()
|
||||
if sv3[-1] not in sv2: sv3.reverse()
|
||||
|
||||
verts,edges,faces = [],[],[]
|
||||
for i in range(l0):
|
||||
l,r = sv0[i],sv2[i]
|
||||
for j in range(l1):
|
||||
t,b = sv1[j],sv3[j]
|
||||
if i == 0: verts += [b]
|
||||
elif i == l0-1: verts += [t]
|
||||
elif j == 0: verts += [l]
|
||||
elif j == l1-1: verts += [r]
|
||||
else:
|
||||
pi,pj = i / (l0-1), j / (l1-1)
|
||||
lr = Vec(l.co)*(1-pj) + Vec(r.co)*pj
|
||||
tb = Vec(b.co)*(1-pi) + Vec(t.co)*pi
|
||||
verts += [nearest_sources_Point((lr+tb)/2.0)]
|
||||
edges += [(i*l1+(j+0), i*l1+(j+1)) for i in range(1,l0-1) for j in range(l1-1)]
|
||||
edges += [((i+0)*l1+j, (i+1)*l1+j) for j in range(1,l1-1) for i in range(l0-1)]
|
||||
faces += [( (i+0)*l1+(j+0), (i+1)*l1+(j+0), (i+1)*l1+(j+1), (i+0)*l1+(j+1) ) for i in range(l0-1) for j in range(l1-1)]
|
||||
|
||||
self.previz += [{ 'type': 'rect', 'data': shape, 'verts': verts, 'edges': edges, 'faces': faces }]
|
||||
|
||||
for shape in self.shapes['L']:
|
||||
s0,s1 = shape
|
||||
sv0,sv1 = get_verts(s0),get_verts(s1)
|
||||
l0,l1 = len(sv0),len(sv1)
|
||||
|
||||
# make sure each strip is in the correct order
|
||||
if sv0[-1] not in sv1: sv0.reverse()
|
||||
if sv1[0] not in sv0: sv1.reverse()
|
||||
|
||||
symmetry0 = self.rfcontext.get_point_symmetry(sv0[0].co)
|
||||
symmetry1 = self.rfcontext.get_point_symmetry(sv1[-1].co)
|
||||
if symmetry0 and symmetry1:
|
||||
# both are at symmetry... artist is trying to fill a triangle
|
||||
# we cannot do that, yet, so bail!
|
||||
continue
|
||||
|
||||
off0,off1 = sv0[-1].co-sv0[0].co, sv1[-1].co-sv1[0].co
|
||||
|
||||
verts,edges,faces = [],[],[]
|
||||
for i in range(l0):
|
||||
for j in range(l1):
|
||||
if i == l0-1: verts += [sv1[j]]
|
||||
elif j == 0: verts += [sv0[i]]
|
||||
else:
|
||||
l,r = sv0[i].co,sv0[i].co+off1
|
||||
t,b = sv1[j].co,sv1[j].co-off0
|
||||
pi,pj = i / (l0-1), j / (l1-1)
|
||||
lr = Vec(l)*(1-pj) + Vec(r)*pj
|
||||
tb = Vec(b)*(1-pi) + Vec(t)*pi
|
||||
point = nearest_sources_Point((lr+tb)/2.0)
|
||||
if i == 0: point = self.rfcontext.snap_to_symmetry(point, symmetry0)
|
||||
if j == l1-1: point = self.rfcontext.snap_to_symmetry(point, symmetry1)
|
||||
verts += [point]
|
||||
edges += [(i*l1+(j+0), i*l1+(j+1)) for i in range(l0-1) for j in range(l1-1)]
|
||||
edges += [((i+0)*l1+j, (i+1)*l1+j) for j in range(1,l1) for i in range(l0-1)]
|
||||
faces += [( (i+0)*l1+(j+0), (i+1)*l1+(j+0), (i+1)*l1+(j+1), (i+0)*l1+(j+1) ) for i in range(l0-1) for j in range(l1-1)]
|
||||
|
||||
self.previz += [{ 'type': 'L', 'data': shape, 'verts': verts, 'edges': edges, 'faces': faces }]
|
||||
|
||||
for shape in self.shapes['C']:
|
||||
s0,s1,s2 = shape
|
||||
if len(s0) != len(s2): continue # invalid C-shape
|
||||
sv0,sv1,sv2 = get_verts(s0),get_verts(s1),get_verts(s2,True)
|
||||
l0,l1 = len(sv0),len(sv1)
|
||||
|
||||
# make sure each strip is in the correct order
|
||||
if sv0[-1] not in sv1: sv0.reverse()
|
||||
if sv1[-1] not in sv2: sv1.reverse()
|
||||
if sv2[-1] not in sv1: sv2.reverse()
|
||||
|
||||
symmetry0 = self.rfcontext.get_point_symmetry(sv0[0].co)
|
||||
symmetry2 = self.rfcontext.get_point_symmetry(sv2[0].co)
|
||||
use_symmetry = (symmetry0 == symmetry2)
|
||||
#print([v.co for v in sv0])
|
||||
#print([v.co for v in sv2])
|
||||
#print(symmetry0, symmetry2, use_symmetry)
|
||||
|
||||
off0,off2 = sv0[0].co-sv0[-1].co, sv2[0].co-sv2[-1].co
|
||||
|
||||
verts,edges,faces = [],[],[]
|
||||
for i in range(l0):
|
||||
for j in range(l1):
|
||||
if i == l0-1: verts += [sv1[j]]
|
||||
elif j == 0: verts += [sv0[i]]
|
||||
elif j == l1-1: verts += [sv2[i]]
|
||||
else:
|
||||
pi,pj = i / (l0-1), j / (l1-1)
|
||||
off = off0*(1-pj)+off2*pj
|
||||
l,r = sv0[i].co,sv2[i].co
|
||||
t,b = sv1[j].co,sv1[j].co+off
|
||||
lr = Vec(l)*(1-pj) + Vec(r)*pj
|
||||
tb = Vec(b)*(1-pi) + Vec(t)*pi
|
||||
point = nearest_sources_Point((lr+tb)/2.0)
|
||||
if use_symmetry and i == 0: point = self.rfcontext.snap_to_symmetry(point, symmetry0)
|
||||
verts += [point]
|
||||
edges += [(i*l1+(j+0), i*l1+(j+1)) for i in range(l0-1) for j in range(l1-1)]
|
||||
edges += [((i+0)*l1+j, (i+1)*l1+j) for j in range(1,l1-1) for i in range(l0-1)]
|
||||
faces += [( (i+0)*l1+(j+0), (i+1)*l1+(j+0), (i+1)*l1+(j+1), (i+0)*l1+(j+1) ) for i in range(l0-1) for j in range(l1-1)]
|
||||
|
||||
self.previz += [{ 'type': 'C', 'data': shape, 'verts': verts, 'edges': edges, 'faces': faces }]
|
||||
|
||||
# TODO: check sides to make sure that we aren't creating geometry
|
||||
# on a side that already has geometry!
|
||||
for i0,shape0 in enumerate(self.shapes['I']):
|
||||
sv0 = get_verts(shape0[0])
|
||||
dir0 = Direction(sv0[0].co-sv0[-1].co)
|
||||
best_sv1,best_dist = None,0
|
||||
for i1,shape1 in enumerate(self.shapes['I']):
|
||||
if i1 <= i0: continue
|
||||
sv1 = get_verts(shape1[0])
|
||||
dir1 = Direction(sv1[0].co-sv1[-1].co)
|
||||
if len(sv0) != len(sv1): continue
|
||||
if dir0.dot(dir1) < 0:
|
||||
sv1 = list(reversed(sv1))
|
||||
dir1.reverse()
|
||||
# make sure the I strip are good candidates for bridging
|
||||
# if math.degrees(dir0.angleBetween(dir1)) > 80: continue # make sure strips are parallel enough
|
||||
if math.degrees(dir0.angleBetween(Direction(sv1[0].co-sv0[0].co))) < 45: continue
|
||||
if math.degrees(dir1.angleBetween(Direction(sv0[0].co-sv1[0].co))) < 45: continue
|
||||
dist = min((v0.co-v1.co).length for v0 in sv0 for v1 in sv1)
|
||||
if best_sv1 and best_dist < dist: continue
|
||||
best_sv1 = sv1
|
||||
best_dist = dist
|
||||
if not best_sv1: continue
|
||||
sv1,dist = best_sv1,best_dist
|
||||
avg0 = (sv0[0].co-sv0[-1].co).length / (len(sv0)-1)
|
||||
avg1 = (sv1[0].co-sv1[-1].co).length / (len(sv1)-1)
|
||||
|
||||
l0 = len(sv0)
|
||||
if getattr(self, 'crosses', None) is None:
|
||||
self.crosses = max(2, math.floor(dist / max(avg0,avg1)))
|
||||
l1 = self.crosses
|
||||
|
||||
verts,edges,faces = [],[],[]
|
||||
for i in range(l0):
|
||||
for j in range(l1):
|
||||
if j == 0: verts += [sv0[i]]
|
||||
elif j == l1-1: verts += [sv1[i]]
|
||||
else:
|
||||
pi,pj = i / (l0-1), j / (l1-1)
|
||||
l,r = sv0[i].co,sv1[i].co
|
||||
lr = Vec(l)*(1-pj) + Vec(r)*pj
|
||||
verts += [nearest_sources_Point(lr)]
|
||||
edges += [(i*l1+(j+0), i*l1+(j+1)) for i in range(l0) for j in range(l1-1)]
|
||||
edges += [((i+0)*l1+j, (i+1)*l1+j) for j in range(1,l1-1) for i in range(l0-1)]
|
||||
faces += [( (i+0)*l1+(j+0), (i+1)*l1+(j+0), (i+1)*l1+(j+1), (i+0)*l1+(j+1) ) for i in range(l0-1) for j in range(l1-1)]
|
||||
|
||||
self.previz += [{ 'type': 'I', 'data': shape0, 'verts': verts, 'edges': edges, 'faces': faces }]
|
||||
|
||||
|
||||
if False:
|
||||
print('')
|
||||
print('patches info:')
|
||||
print(' %d edges' % len(edges))
|
||||
print(' %d strips' % len(strips))
|
||||
print(' %d corners' % len(corners))
|
||||
print(' %d string corners' % len(string_corners))
|
||||
print(' %d loop corners' % len(loop_corners))
|
||||
print(' %d strings' % len(strings_strips))
|
||||
print(' %d loops' % len(loops_strips))
|
||||
for d,k in [('loop','O'),('loop','eye'),('loop','tri'),('loop','rect'),('loop','ngon'),('string','I'),('string','L'),('string','C'),('string','else')]:
|
||||
print(' %d %s-shaped %s' % (len(self.shapes[k]), k, d))
|
||||
|
||||
tag_redraw_all('Patches recompute')
|
||||
self.update_ui()
|
||||
@@ -0,0 +1,13 @@
|
||||
<details>
|
||||
<summary>Patches</summary>
|
||||
<div class="contents">
|
||||
<div class="labeled-input-text">
|
||||
<label for="patches-angle">Angle</label>
|
||||
<input id="patches-angle" type="number" value="BoundInt('''options['patches angle']''', min_value=0, max_value=180)" title="A vertex between connected edges that form an angles below this threshold is a corner">
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="patches-crosses">Crosses</label>
|
||||
<input id="patches-crosses" type="number" value="BoundInt('''self.var_crosses''', min_value=1, max_value=500)" title="Number of crosses">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@@ -0,0 +1,278 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import random
|
||||
|
||||
from mathutils.geometry import intersect_line_line_2d as intersect2d_segment_segment
|
||||
|
||||
from ..rftool import RFTool
|
||||
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
|
||||
from ..rfwidgets.rfwidget_hidden import RFWidget_Hidden_Factory
|
||||
from ..rfmesh.rfmesh_wrapper import RFVert, RFEdge, RFFace
|
||||
|
||||
from ...addon_common.common import gpustate
|
||||
from ...addon_common.common.drawing import (
|
||||
CC_DRAW,
|
||||
CC_2D_POINTS,
|
||||
CC_2D_LINES, CC_2D_LINE_LOOP,
|
||||
CC_2D_TRIANGLES, CC_2D_TRIANGLE_FAN,
|
||||
)
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.maths import Point, Point2D, Vec2D, Vec, Direction2D, intersection2d_line_line, closest2d_point_segment
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.utils import iter_pairs
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.drawing import DrawCallbacks
|
||||
from ...addon_common.common.boundvar import BoundBool, BoundInt, BoundFloat, BoundString
|
||||
from ...addon_common.common.timerhandler import CallGovernor
|
||||
from ...addon_common.common.debug import dprint
|
||||
|
||||
|
||||
from .polypen_insert import PolyPen_Insert
|
||||
|
||||
|
||||
from ...config.options import options, themes
|
||||
|
||||
|
||||
class PolyPen(RFTool, PolyPen_Insert):
|
||||
name = 'PolyPen'
|
||||
description = 'Create complex topology on vertex-by-vertex basis'
|
||||
icon = 'polypen-icon.png'
|
||||
help = 'polypen.md'
|
||||
shortcut = 'polypen tool'
|
||||
statusbar = '{{insert}} Insert'
|
||||
ui_config = 'polypen_options.html'
|
||||
|
||||
RFWidget_Default = RFWidget_Default_Factory.create()
|
||||
RFWidget_Crosshair = RFWidget_Default_Factory.create(cursor='CROSSHAIR')
|
||||
RFWidget_Move = RFWidget_Default_Factory.create(cursor='HAND')
|
||||
RFWidget_Knife = RFWidget_Default_Factory.create(cursor='KNIFE')
|
||||
RFWidget_Hidden = RFWidget_Hidden_Factory.create()
|
||||
|
||||
@RFTool.on_init
|
||||
def init(self):
|
||||
self.rfwidgets = {
|
||||
'default': self.RFWidget_Default(self),
|
||||
'insert': self.RFWidget_Crosshair(self),
|
||||
'hover': self.RFWidget_Move(self),
|
||||
'knife': self.RFWidget_Knife(self),
|
||||
'hidden': self.RFWidget_Hidden(self),
|
||||
}
|
||||
self.rfwidget = None
|
||||
self.next_state = 'unset'
|
||||
self.nearest_vert, self.nearest_edge, self.nearest_face, self.nearest_geom = None, None, None, None
|
||||
self.vis_verts, self.vis_edges, self.vis_faces = [], [], []
|
||||
self.update_selection()
|
||||
self._var_merge_dist = BoundFloat( '''options['polypen merge dist'] ''')
|
||||
self._var_automerge = BoundBool( '''options['polypen automerge'] ''')
|
||||
self._var_insert_mode = BoundString('''options['polypen insert mode']''')
|
||||
|
||||
def _fsm_in_main(self):
|
||||
# needed so main actions using Ctrl (ex: undo, redo, save) can still work
|
||||
return self._fsm.state in {'main', 'previs insert'}
|
||||
|
||||
def update_insert_mode(self):
|
||||
mode = options['polypen insert mode']
|
||||
self.ui_options_label.innerText = f'PolyPen: {mode}'
|
||||
self.ui_insert_modes.dirty(cause='insert mode change', children=True)
|
||||
|
||||
@RFTool.on_ui_setup
|
||||
def ui(self):
|
||||
ui_options = self.document.body.getElementById('polypen-options')
|
||||
self.ui_options_label = ui_options.getElementById('polypen-summary-label')
|
||||
self.ui_insert_modes = ui_options.getElementById('polypen-insert-modes')
|
||||
self.update_insert_mode()
|
||||
|
||||
|
||||
@RFTool.on_reset
|
||||
@RFTool.on_target_change
|
||||
@FSM.onlyinstate('main')
|
||||
def update_selection(self):
|
||||
self.sel_verts, self.sel_edges, self.sel_faces = self.rfcontext.get_selected_geom()
|
||||
|
||||
@RFTool.on_events('reset', 'target change', 'view change', 'mouse move')
|
||||
@RFTool.not_while_navigating
|
||||
@FSM.onlyinstate('main')
|
||||
def update_nearest(self):
|
||||
self.nearest_vert,_ = self.rfcontext.accel_nearest2D_vert(max_dist=options['polypen merge dist'], selected_only=True)
|
||||
self.nearest_edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['polypen merge dist'], selected_only=True)
|
||||
self.nearest_face,_ = self.rfcontext.accel_nearest2D_face(max_dist=options['polypen merge dist'], selected_only=True)
|
||||
self.nearest_geom = self.nearest_vert or self.nearest_edge or self.nearest_face
|
||||
|
||||
@FSM.on_state('main', 'enter')
|
||||
def main_enter(self):
|
||||
self.update_selection()
|
||||
self.update_nearest()
|
||||
|
||||
@FSM.on_state('main')
|
||||
def main(self):
|
||||
if self.actions.using_onlymods('insert'):
|
||||
return 'previs insert'
|
||||
|
||||
if self.nearest_geom and self.nearest_geom.select:
|
||||
self.set_widget('hover')
|
||||
else:
|
||||
self.set_widget('default')
|
||||
|
||||
if self.handle_inactive_passthrough(): return
|
||||
|
||||
if self.actions.pressed('pie menu alt0'):
|
||||
def callback(option):
|
||||
if not option: return
|
||||
options['polypen insert mode'] = option
|
||||
self.update_insert_mode()
|
||||
self.rfcontext.show_pie_menu([
|
||||
'Tri/Quad',
|
||||
'Quad-Only',
|
||||
'Tri-Only',
|
||||
'Edge-Only',
|
||||
], callback, highlighted=options['polypen insert mode'])
|
||||
return
|
||||
|
||||
if self.nearest_geom and self.nearest_geom.select and self.actions.pressed('action'):
|
||||
self.rfcontext.undo_push('grab')
|
||||
self.prep_move(
|
||||
action_confirm=lambda: self.actions.released('action', ignoremods=True),
|
||||
)
|
||||
return 'move after select'
|
||||
|
||||
if self.actions.pressed('grab'):
|
||||
self.rfcontext.undo_push('move grabbed')
|
||||
self.prep_move(
|
||||
action_confirm=lambda: self.actions.pressed({'confirm', 'confirm drag'}),
|
||||
)
|
||||
return 'move'
|
||||
|
||||
if self.actions.pressed({'select path add'}):
|
||||
return self.rfcontext.select_path(
|
||||
{'edge', 'face'},
|
||||
kwargs_select={'supparts': False},
|
||||
)
|
||||
|
||||
if self.actions.pressed({'select paint', 'select paint add'}, unpress=False):
|
||||
sel_only = self.actions.pressed('select paint')
|
||||
self.actions.unpress()
|
||||
return self.rfcontext.setup_smart_selection_painting(
|
||||
{'vert','edge','face'},
|
||||
use_select_tool=True,
|
||||
selecting=not sel_only,
|
||||
deselect_all=sel_only,
|
||||
kwargs_select={'supparts': False},
|
||||
kwargs_deselect={'subparts': False},
|
||||
)
|
||||
|
||||
if self.actions.pressed({'select single', 'select single add'}, unpress=False):
|
||||
sel_only = self.actions.pressed('select single')
|
||||
self.actions.unpress()
|
||||
bmv,_ = self.rfcontext.accel_nearest2D_vert(max_dist=options['select dist'])
|
||||
bme,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['select dist'])
|
||||
bmf,_ = self.rfcontext.accel_nearest2D_face(max_dist=options['select dist'])
|
||||
sel = bmv or bme or bmf
|
||||
if not sel_only and not sel: return
|
||||
self.rfcontext.undo_push('select')
|
||||
if sel_only: self.rfcontext.deselect_all()
|
||||
if not sel: return
|
||||
if sel.select: self.rfcontext.deselect(sel, subparts=False)
|
||||
else: self.rfcontext.select(sel, supparts=False, only=sel_only)
|
||||
return
|
||||
|
||||
|
||||
@FSM.on_state('move after select')
|
||||
def modal_move_after_select(self):
|
||||
if self.actions.released('action'):
|
||||
return 'main'
|
||||
|
||||
if (self.actions.mouse - self.mousedown).length >= self.rfcontext.drawing.scale(options['move dist']):
|
||||
self.rfcontext.undo_push('move after select')
|
||||
return 'move'
|
||||
|
||||
def prep_move(self, *, bmverts=None, action_confirm=None, action_cancel=None, defer_recomputing=True):
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
self.bmverts = [bmv for bmv in bmverts if bmv and bmv.is_valid] if bmverts is not None else self.rfcontext.get_selected_verts()
|
||||
self.bmverts_xys = [
|
||||
(bmv, xy)
|
||||
for bmv in self.bmverts
|
||||
if bmv and bmv.is_valid and (xy := Point_to_Point2D(bmv.co)) is not None
|
||||
]
|
||||
self.move_actions = {
|
||||
'confirm': action_confirm or (lambda: self.actions.pressed('confirm')),
|
||||
'cancel': action_cancel or (lambda: self.actions.pressed('cancel')),
|
||||
}
|
||||
self.mousedown = self.actions.mouse
|
||||
self.last_delta = None
|
||||
self.defer_recomputing = defer_recomputing
|
||||
|
||||
@FSM.on_state('move', 'enter')
|
||||
def move_enter(self):
|
||||
self.move_vis_accel = self.rfcontext.get_accel_visible(selected_only=False)
|
||||
# if not self.move_done_released and options['hide cursor on tweak']: self.set_widget('hidden')
|
||||
if options['hide cursor on tweak']: self.set_widget('hidden')
|
||||
self.rfcontext.split_target_visualization_selected()
|
||||
self.rfcontext.fast_update_timer.start()
|
||||
self.rfcontext.set_accel_defer(True)
|
||||
self.last_delta = None
|
||||
|
||||
@FSM.on_state('move')
|
||||
def modal_move(self):
|
||||
if self.move_actions['confirm']():
|
||||
self.defer_recomputing = False
|
||||
if options['polypen automerge']:
|
||||
self.rfcontext.merge_verts_by_dist(self.bmverts, options['polypen merge dist'])
|
||||
return 'main'
|
||||
|
||||
if self.move_actions['cancel']():
|
||||
self.defer_recomputing = False
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
|
||||
@RFTool.on_mouse_move
|
||||
@RFTool.once_per_frame
|
||||
@FSM.onlyinstate('move')
|
||||
def modal_move_update(self):
|
||||
delta = Vec2D(self.actions.mouse - self.mousedown)
|
||||
if delta == self.last_delta: return
|
||||
self.last_delta = delta
|
||||
set2D_vert = self.rfcontext.set2D_vert
|
||||
|
||||
for bmv,xy in self.bmverts_xys:
|
||||
if not xy: continue
|
||||
xy_updated = xy + delta
|
||||
# check if xy_updated is "close" to any visible verts (in image plane)
|
||||
# if so, snap xy_updated to vert position (in image plane)
|
||||
if options['polypen automerge']:
|
||||
bmv1,_ = self.rfcontext.accel_nearest2D_vert(point=xy_updated, vis_accel=self.move_vis_accel, max_dist=options['polypen merge dist'])
|
||||
if bmv1:
|
||||
xy_updated = self.rfcontext.Point_to_Point2D(bmv1.co)
|
||||
set2D_vert(bmv, xy_updated)
|
||||
|
||||
self.rfcontext.update_verts_faces(self.bmverts)
|
||||
self.rfcontext.dirty()
|
||||
tag_redraw_all('polypen mouse move')
|
||||
|
||||
@FSM.on_state('move', 'exit')
|
||||
def move_exit(self):
|
||||
self.rfcontext.fast_update_timer.stop()
|
||||
self.rfcontext.set_accel_defer(False)
|
||||
self.rfcontext.clear_split_target_visualization()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import random
|
||||
|
||||
from mathutils.geometry import intersect_line_line_2d as intersect2d_segment_segment
|
||||
|
||||
from ..rftool import RFTool
|
||||
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
|
||||
from ..rfwidgets.rfwidget_hidden import RFWidget_Hidden_Factory
|
||||
from ..rfmesh.rfmesh_wrapper import RFVert, RFEdge, RFFace
|
||||
|
||||
from ...addon_common.common import gpustate
|
||||
from ...addon_common.common.drawing import (
|
||||
CC_DRAW,
|
||||
CC_2D_POINTS,
|
||||
CC_2D_LINES, CC_2D_LINE_LOOP,
|
||||
CC_2D_TRIANGLES, CC_2D_TRIANGLE_FAN,
|
||||
)
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.maths import Point, Point2D, Vec2D, Vec, Direction2D, intersection2d_line_line, closest2d_point_segment
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.utils import iter_pairs
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.drawing import DrawCallbacks
|
||||
from ...addon_common.common.boundvar import BoundBool, BoundInt, BoundFloat, BoundString
|
||||
from ...addon_common.common.timerhandler import CallGovernor
|
||||
from ...addon_common.common.debug import dprint
|
||||
|
||||
|
||||
from ...config.options import options, themes
|
||||
|
||||
|
||||
class PolyPen_Insert():
|
||||
@RFTool.on_events('target change')
|
||||
@FSM.onlyinstate('previs insert')
|
||||
@RFTool.not_while_navigating
|
||||
def gather_selection(self):
|
||||
self.sel_verts, self.sel_edges, self.sel_faces = self.rfcontext.get_selected_geom()
|
||||
self.num_sel_verts, self.num_sel_edges, self.num_sel_faces = len(self.sel_verts), len(self.sel_edges), len(self.sel_faces)
|
||||
|
||||
@RFTool.on_events('target change', 'view change')
|
||||
@FSM.onlyinstate('previs insert')
|
||||
@RFTool.not_while_navigating
|
||||
def gather_visible(self):
|
||||
self.vis_verts, self.vis_edges, self.vis_faces = self.rfcontext.get_vis_geom()
|
||||
|
||||
@FSM.on_state('previs insert', 'enter')
|
||||
def modal_previs_enter(self):
|
||||
self.draw_coords = []
|
||||
self.gather_visible()
|
||||
self.gather_selection()
|
||||
self.set_next_state()
|
||||
self.rfcontext.fast_update_timer.enable(True)
|
||||
self.modal_previs_mousemove()
|
||||
tag_redraw_all('PolyPen insert mouse move')
|
||||
|
||||
@RFTool.on_mouse_move
|
||||
@FSM.onlyinstate('previs insert')
|
||||
def modal_previs_mousemove(self):
|
||||
if self.next_state == 'knife selected edge':
|
||||
self.set_widget('knife')
|
||||
else:
|
||||
self.set_widget('insert')
|
||||
|
||||
@FSM.on_state('previs insert')
|
||||
def modal_previs(self):
|
||||
if self.handle_inactive_passthrough(): return
|
||||
|
||||
if self.actions.pressed('insert'):
|
||||
return 'insert'
|
||||
|
||||
if not self.actions.using_onlymods('insert'):
|
||||
return 'main'
|
||||
|
||||
|
||||
@FSM.on_state('previs insert', 'exit')
|
||||
def modal_previs_exit(self):
|
||||
self.rfcontext.fast_update_timer.enable(False)
|
||||
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate('previs insert')
|
||||
@RFTool.not_while_navigating
|
||||
def draw_postpixel(self):
|
||||
gpustate.blend('ALPHA')
|
||||
CC_DRAW.stipple(pattern=[4,4])
|
||||
CC_DRAW.point_size(8)
|
||||
CC_DRAW.line_width(2)
|
||||
|
||||
poly_alpha = 0.2
|
||||
line_color = themes['new']
|
||||
poly_color = [line_color[0], line_color[1], line_color[2], line_color[3] * poly_alpha]
|
||||
|
||||
for coords in self.draw_coords:
|
||||
coords = [self.rfcontext.Point_to_Point2D(co) for co in coords]
|
||||
if not all(coords): return
|
||||
|
||||
match len(coords):
|
||||
case 1:
|
||||
with Globals.drawing.draw(CC_2D_POINTS) as draw:
|
||||
draw.color(line_color)
|
||||
for c in coords:
|
||||
draw.vertex(c)
|
||||
case 2:
|
||||
with Globals.drawing.draw(CC_2D_LINES) as draw:
|
||||
draw.color(line_color)
|
||||
draw.vertex(coords[0])
|
||||
draw.vertex(coords[1])
|
||||
case _:
|
||||
with Globals.drawing.draw(CC_2D_LINE_LOOP) as draw:
|
||||
draw.color(line_color)
|
||||
for co in coords: draw.vertex(co)
|
||||
|
||||
with Globals.drawing.draw(CC_2D_TRIANGLE_FAN) as draw:
|
||||
draw.color(poly_color)
|
||||
draw.vertex(coords[0])
|
||||
for co1,co2 in iter_pairs(coords[1:], False):
|
||||
draw.vertex(co1)
|
||||
draw.vertex(co2)
|
||||
|
||||
CC_DRAW.stipple()
|
||||
|
||||
|
||||
@FSM.on_state('insert')
|
||||
def insert(self):
|
||||
self.rfcontext.undo_push('insert')
|
||||
return self._insert()
|
||||
|
||||
|
||||
@RFTool.on_mouse_move
|
||||
@RFTool.once_per_frame
|
||||
# @RFTool.on_events('new frame')
|
||||
@RFTool.not_while_navigating
|
||||
@FSM.onlyinstate('previs insert')
|
||||
def set_next_state(self):
|
||||
'''
|
||||
determines what the next state will be, based on selected mode, selected geometry, and hovered geometry
|
||||
'''
|
||||
|
||||
self.draw_coords = []
|
||||
self.nearest_vert, self.nearest_edge, self.nearest_face, self.nearest_geom = None, None, None, None
|
||||
self.insert_edge = None
|
||||
|
||||
if not self.actions.mouse: return
|
||||
hit_pos = self.actions.hit_pos
|
||||
if not hit_pos: return
|
||||
|
||||
if True: # with profiler.code('getting nearest geometry'):
|
||||
self.nearest_vert,_ = self.rfcontext.accel_nearest2D_vert(max_dist=options['polypen merge dist'])
|
||||
self.nearest_edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['polypen merge dist'])
|
||||
self.nearest_face,_ = self.rfcontext.accel_nearest2D_face(max_dist=options['polypen merge dist'])
|
||||
self.nearest_geom = self.nearest_vert or self.nearest_edge or self.nearest_face
|
||||
self.insert_edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['polypen insert dist'])
|
||||
|
||||
if self.insert_edge and self.insert_edge.select: # overriding: if hovering over a selected edge, knife it!
|
||||
self.next_state = 'knife selected edge'
|
||||
|
||||
elif options['polypen insert mode'] == 'Tri/Quad':
|
||||
if self.num_sel_verts == 1 and self.num_sel_edges == 0 and self.num_sel_faces == 0:
|
||||
self.next_state = 'vert-edge'
|
||||
elif self.num_sel_edges and self.num_sel_faces == 0:
|
||||
quad_snap = (
|
||||
(not self.nearest_vert and self.nearest_edge) and
|
||||
(len(self.nearest_edge.link_faces) <= 1) and
|
||||
(not any(v in self.sel_verts for v in self.nearest_edge.verts)) and
|
||||
(not any(e in f.edges for v in self.nearest_edge.verts for f in v.link_faces for e in self.sel_edges))
|
||||
)
|
||||
if quad_snap:
|
||||
self.next_state = 'edge-quad-snap'
|
||||
else:
|
||||
self.next_state = 'edge-face'
|
||||
elif self.num_sel_verts == 3 and self.num_sel_edges == 3 and self.num_sel_faces == 1:
|
||||
self.next_state = 'tri-quad'
|
||||
else:
|
||||
self.next_state = 'new vertex'
|
||||
|
||||
elif options['polypen insert mode'] == 'Quad-Only':
|
||||
# a Desmos construction of how this works: https://www.desmos.com/geometry/bmmx206thi
|
||||
if self.num_sel_verts == 1 and self.num_sel_edges == 0 and self.num_sel_faces == 0:
|
||||
self.next_state = 'vert-edge'
|
||||
elif self.num_sel_edges:
|
||||
quad_snap = (
|
||||
(not self.nearest_vert and self.nearest_edge) and
|
||||
(len(self.nearest_edge.link_faces) <= 1) and
|
||||
(not any(v in self.sel_verts for v in self.nearest_edge.verts)) and
|
||||
(not any(e in f.edges for v in self.nearest_edge.verts for f in v.link_faces for e in self.sel_edges))
|
||||
)
|
||||
self.next_state = 'edge-quad-snap' if quad_snap else 'edge-quad'
|
||||
else:
|
||||
self.next_state = 'new vertex'
|
||||
|
||||
elif options['polypen insert mode'] == 'Tri-Only':
|
||||
if self.num_sel_verts == 1 and self.num_sel_edges == 0 and self.num_sel_faces == 0:
|
||||
self.next_state = 'vert-edge'
|
||||
elif self.num_sel_edges and self.num_sel_faces == 0:
|
||||
quad = (
|
||||
(not self.nearest_vert and self.nearest_edge) and
|
||||
(len(self.nearest_edge.link_faces) <= 1) and
|
||||
(not any(v in self.sel_verts for v in self.nearest_edge.verts)) and
|
||||
(not any(e in f.edges for v in self.nearest_edge.verts for f in v.link_faces for e in self.sel_edges))
|
||||
)
|
||||
if quad:
|
||||
self.next_state = 'edge-quad-snap'
|
||||
else:
|
||||
self.next_state = 'edge-face'
|
||||
elif self.num_sel_verts == 3 and self.num_sel_edges == 3 and self.num_sel_faces == 1:
|
||||
self.next_state = 'edge-face'
|
||||
else:
|
||||
self.next_state = 'new vertex'
|
||||
|
||||
elif options['polypen insert mode'] == 'Edge-Only':
|
||||
if self.num_sel_verts == 0:
|
||||
self.next_state = 'new vertex'
|
||||
else:
|
||||
if self.insert_edge:
|
||||
self.next_state = 'vert-edge'
|
||||
else:
|
||||
self.next_state = 'vert-edge-vert'
|
||||
|
||||
else:
|
||||
assert False, f'Unhandled PolyPen insert mode: {options["polypen insert mode"]}'
|
||||
|
||||
tag_redraw_all('PolyPen next state')
|
||||
|
||||
match self.next_state:
|
||||
case 'unset':
|
||||
return
|
||||
|
||||
case 'knife selected edge':
|
||||
bmv1,bmv2 = self.insert_edge.verts
|
||||
faces = self.insert_edge.link_faces
|
||||
if faces:
|
||||
for f in faces:
|
||||
lco = []
|
||||
for v0,v1 in iter_pairs(f.verts, True):
|
||||
lco.append(v0.co)
|
||||
if (v0 == bmv1 and v1 == bmv2) or (v0 == bmv2 and v1 == bmv1):
|
||||
lco.append(hit_pos)
|
||||
self.draw_coords.append(lco)
|
||||
else:
|
||||
self.draw_coords.append([bmv1.co, hit_pos])
|
||||
self.draw_coords.append([bmv2.co, hit_pos])
|
||||
return
|
||||
|
||||
case 'new vertex':
|
||||
p0 = hit_pos
|
||||
if self.insert_edge:
|
||||
bmv1,bmv2 = self.insert_edge.verts
|
||||
if f := next(iter(self.insert_edge.link_faces), None):
|
||||
lco = []
|
||||
for v0,v1 in iter_pairs(f.verts, True):
|
||||
lco.append(v0.co)
|
||||
if (v0 == bmv1 and v1 == bmv2) or (v0 == bmv2 and v1 == bmv1):
|
||||
lco.append(p0)
|
||||
self.draw_coords.append(lco)
|
||||
else:
|
||||
self.draw_coords.append([bmv1.co, hit_pos])
|
||||
self.draw_coords.append([bmv2.co, hit_pos])
|
||||
else:
|
||||
self.draw_coords.append([hit_pos])
|
||||
return
|
||||
|
||||
case 'vert-edge' | 'vert-edge-vert':
|
||||
bmv0,_ = self.rfcontext.nearest2D_vert(verts=self.sel_verts)
|
||||
if self.nearest_vert:
|
||||
p0 = self.nearest_vert.co
|
||||
elif self.next_state == 'vert-edge':
|
||||
p0 = hit_pos
|
||||
if self.insert_edge:
|
||||
bmv1,bmv2 = self.insert_edge.verts
|
||||
if f := next(iter(self.insert_edge.link_faces), None):
|
||||
lco = []
|
||||
for v0,v1 in iter_pairs(f.verts, True):
|
||||
lco.append(v0.co)
|
||||
if (v0 == bmv1 and v1 == bmv2) or (v0 == bmv2 and v1 == bmv1):
|
||||
lco.append(p0)
|
||||
self.draw_coords.append(lco)
|
||||
else:
|
||||
self.draw_coords.append([bmv1.co, p0])
|
||||
self.draw_coords.append([bmv2.co, p0])
|
||||
elif self.next_state == 'vert-edge-vert':
|
||||
p0 = hit_pos
|
||||
else:
|
||||
return
|
||||
if bmv0: self.draw_coords.append([bmv0.co, p0])
|
||||
return
|
||||
|
||||
case 'edge-face':
|
||||
e0,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
|
||||
e1 = self.insert_edge
|
||||
if not e0: return
|
||||
if e1 and e0 == e1:
|
||||
bmv1,bmv2 = e1.verts
|
||||
p0 = hit_pos
|
||||
f = next(iter(e1.link_faces), None)
|
||||
if f:
|
||||
lco = []
|
||||
for v0,v1 in iter_pairs(f.verts, True):
|
||||
lco.append(v0.co)
|
||||
if (v0 == bmv1 and v1 == bmv2) or (v0 == bmv2 and v1 == bmv1):
|
||||
lco.append(p0)
|
||||
self.draw_coords.append(lco)
|
||||
else:
|
||||
self.draw_coords.append([bmv1.co, hit_pos])
|
||||
self.draw_coords.append([bmv2.co, hit_pos])
|
||||
else:
|
||||
# self.draw_coords.append([hit_pos])
|
||||
bmv1,bmv2 = e0.verts
|
||||
if self.nearest_vert and not self.nearest_vert.select:
|
||||
p0 = self.nearest_vert.co
|
||||
else:
|
||||
p0 = hit_pos
|
||||
self.draw_coords.append([p0, bmv1.co, bmv2.co])
|
||||
return
|
||||
|
||||
case 'edge-quad':
|
||||
# a Desmos construction of how this works: https://www.desmos.com/geometry/bmmx206thi
|
||||
xy0, xy1, xy2, xy3 = self._get_edge_quad_verts()
|
||||
if xy0 is None: return
|
||||
co0 = self.rfcontext.raycast_sources_Point2D(xy0)[0]
|
||||
co1 = self.rfcontext.raycast_sources_Point2D(xy1)[0]
|
||||
co2 = self.rfcontext.raycast_sources_Point2D(xy2)[0]
|
||||
co3 = self.rfcontext.raycast_sources_Point2D(xy3)[0]
|
||||
self.draw_coords.append([co1, co2, co3, co0])
|
||||
return
|
||||
|
||||
case 'edge-quad-snap':
|
||||
e0,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
|
||||
e1 = self.nearest_edge
|
||||
if not e0 or not e1: return
|
||||
bmv0,bmv1 = e0.verts
|
||||
bmv2,bmv3 = e1.verts
|
||||
p0,p1 = self.rfcontext.Point_to_Point2D(bmv0.co),self.rfcontext.Point_to_Point2D(bmv1.co)
|
||||
p2,p3 = self.rfcontext.Point_to_Point2D(bmv2.co),self.rfcontext.Point_to_Point2D(bmv3.co)
|
||||
if intersect2d_segment_segment(p1, p2, p3, p0): bmv2,bmv3 = bmv3,bmv2
|
||||
# if e0.vector2D(self.rfcontext.Point_to_Point2D).dot(e1.vector2D(self.rfcontext.Point_to_Point2D)) > 0:
|
||||
# bmv2,bmv3 = bmv3,bmv2
|
||||
self.draw_coords.append([bmv0.co, bmv1.co, bmv2.co, bmv3.co])
|
||||
return
|
||||
|
||||
case 'tri-quad':
|
||||
if self.nearest_vert and not self.nearest_vert.select:
|
||||
p0 = self.nearest_vert.co
|
||||
else:
|
||||
p0 = hit_pos
|
||||
e1,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
|
||||
if not e1: return
|
||||
bmv1,bmv2 = e1.verts
|
||||
f = next(iter(e1.link_faces), None)
|
||||
if not f: return
|
||||
lco = []
|
||||
for v0,v1 in iter_pairs(f.verts, True):
|
||||
lco.append(v0.co)
|
||||
if (v0 == bmv1 and v1 == bmv2) or (v0 == bmv2 and v1 == bmv1):
|
||||
lco.append(p0)
|
||||
self.draw_coords.append(lco)
|
||||
#self.draw_coords.append([p0, bmv1.co, bmv2.co])
|
||||
return
|
||||
|
||||
case _:
|
||||
pass
|
||||
|
||||
# case 'edges-face':
|
||||
# if self.nearest_vert and not self.nearest_vert.select:
|
||||
# p0 = self.nearest_vert.co
|
||||
# else:
|
||||
# p0 = hit_pos
|
||||
# e1,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
|
||||
# bmv1,bmv2 = e1.verts
|
||||
# self.draw_coords.append([p0, bmv1.co, bmv2.co])
|
||||
|
||||
# if self.actions.shift and not self.actions.ctrl:
|
||||
# # TODO: ALTERNATIVE INSERT, BUT NOT BEING USED!?!?
|
||||
# # not in docs, not in main polypen.py FSM state
|
||||
# match self.next_state:
|
||||
# case 'edge-face' | 'edge-quad' | 'edge-quad-snap' | 'tri-quad':
|
||||
# nearest_sel_vert,_ = self.rfcontext.nearest2D_vert(verts=self.sel_verts, max_dist=options['polypen merge dist'])
|
||||
# if nearest_sel_vert:
|
||||
# self.draw_coords.append([nearest_sel_vert.co, hit_pos])
|
||||
# return
|
||||
# case _:
|
||||
# return
|
||||
|
||||
|
||||
def _get_edge_quad_verts(self):
|
||||
'''
|
||||
this function is used in quad-only mode to find positions of quad verts based on selected edge and mouse position
|
||||
a Desmos construction of how this works: https://www.desmos.com/geometry/5w40xowuig
|
||||
'''
|
||||
e0,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
|
||||
if not e0: return (None, None, None, None)
|
||||
bmv0,bmv1 = e0.verts
|
||||
xy0 = self.rfcontext.Point_to_Point2D(bmv0.co)
|
||||
xy1 = self.rfcontext.Point_to_Point2D(bmv1.co)
|
||||
d01 = (xy0 - xy1).length
|
||||
mid01 = xy0 + (xy1 - xy0) / 2
|
||||
mid23 = self.actions.mouse
|
||||
mid0123 = mid01 + (mid23 - mid01) / 2
|
||||
between = mid23 - mid01
|
||||
if between.length < 0.0001: return (None, None, None, None)
|
||||
perp = Direction2D((-between.y, between.x))
|
||||
if perp.dot(xy1 - xy0) < 0: perp.reverse()
|
||||
#pts = intersect_line_line(xy0, xy1, mid0123, mid0123 + perp)
|
||||
#if not pts: return (None, None, None, None)
|
||||
#intersection = pts[1]
|
||||
intersection = intersection2d_line_line(xy0, xy1, mid0123, mid0123 + perp)
|
||||
if not intersection: return (None, None, None, None)
|
||||
intersection = Point2D(intersection)
|
||||
|
||||
toward = Direction2D(mid23 - intersection)
|
||||
if toward.dot(perp) < 0: d01 = -d01
|
||||
|
||||
# push intersection out just a bit to make it more stable (prevent crossing) when |between| < d01
|
||||
between_len = between.length * Direction2D(xy1 - xy0).dot(perp)
|
||||
|
||||
for tries in range(32):
|
||||
v = toward * (d01 / 2)
|
||||
xy2, xy3 = mid23 + v, mid23 - v
|
||||
|
||||
# try to prevent quad from crossing
|
||||
v03 = xy3 - xy0
|
||||
if v03.dot(between) < 0 or v03.length < between_len:
|
||||
xy3 = xy0 + Direction2D(v03) * (between_len * (-1 if v03.dot(between) < 0 else 1))
|
||||
v12 = xy2 - xy1
|
||||
if v12.dot(between) < 0 or v12.length < between_len:
|
||||
xy2 = xy1 + Direction2D(v12) * (between_len * (-1 if v12.dot(between) < 0 else 1))
|
||||
|
||||
if self.rfcontext.raycast_sources_Point2D(xy2)[0] and self.rfcontext.raycast_sources_Point2D(xy3)[0]: break
|
||||
d01 /= 2
|
||||
else:
|
||||
return (None, None, None, None)
|
||||
|
||||
nearest_vert,_ = self.rfcontext.nearest2D_vert(point=xy2, verts=self.vis_verts, max_dist=options['polypen merge dist'])
|
||||
if nearest_vert: xy2 = self.rfcontext.Point_to_Point2D(nearest_vert.co)
|
||||
nearest_vert,_ = self.rfcontext.nearest2D_vert(point=xy3, verts=self.vis_verts, max_dist=options['polypen merge dist'])
|
||||
if nearest_vert: xy3 = self.rfcontext.Point_to_Point2D(nearest_vert.co)
|
||||
|
||||
return (xy0, xy1, xy2, xy3)
|
||||
|
||||
@RFTool.dirty_when_done
|
||||
def _insert(self):
|
||||
if self.actions.shift and not self.actions.ctrl and not self.next_state in ['new vertex', 'vert-edge']:
|
||||
self.next_state = 'vert-edge'
|
||||
nearest_vert,_ = self.rfcontext.nearest2D_vert(verts=self.sel_verts, max_dist=options['polypen merge dist'])
|
||||
self.rfcontext.select(nearest_vert)
|
||||
|
||||
sel_verts = self.sel_verts
|
||||
sel_edges = self.sel_edges
|
||||
sel_faces = self.sel_faces
|
||||
|
||||
if self.next_state == 'knife selected edge': # overriding: if hovering over a selected edge, knife it!
|
||||
# self.nearest_edge and self.nearest_edge.select:
|
||||
#print('knifing selected, hovered edge')
|
||||
bmv = self.rfcontext.new2D_vert_mouse()
|
||||
if not bmv:
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
bme0,bmv2 = self.insert_edge.split()
|
||||
bmv.merge(bmv2)
|
||||
self.rfcontext.select(bmv)
|
||||
self.mousedown = self.actions.mousedown
|
||||
xy = self.rfcontext.Point_to_Point2D(bmv.co)
|
||||
if not xy:
|
||||
#print('Could not insert: ' + str(bmv.co))
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
self.prep_move(
|
||||
bmverts=[bmv],
|
||||
action_confirm=(lambda: self.actions.released('insert')),
|
||||
)
|
||||
return 'move'
|
||||
|
||||
if self.next_state in {'vert-edge', 'vert-edge-vert'}:
|
||||
bmv0,_ = self.rfcontext.nearest2D_vert(verts=self.sel_verts)
|
||||
|
||||
if self.next_state == 'vert-edge':
|
||||
if self.nearest_vert:
|
||||
bmv1 = self.nearest_vert
|
||||
if bmv0 == bmv1:
|
||||
self.prep_move(
|
||||
bmverts=[bmv0],
|
||||
action_confirm=(lambda: self.actions.released('insert')),
|
||||
)
|
||||
return 'move'
|
||||
lbmf = bmv0.shared_faces(bmv1)
|
||||
bme = bmv0.shared_edge(bmv1)
|
||||
if len(lbmf) == 1 and not bmv0.share_edge(bmv1):
|
||||
# split face
|
||||
bmf = lbmf[0]
|
||||
bmf.split(bmv0, bmv1)
|
||||
self.rfcontext.select(bmv1)
|
||||
return 'main'
|
||||
if not bme:
|
||||
bme = self.rfcontext.new_edge((bmv0, bmv1))
|
||||
self.rfcontext.select(bme)
|
||||
self.prep_move(
|
||||
bmverts=[bmv1],
|
||||
action_confirm=(lambda: self.actions.released('insert')),
|
||||
)
|
||||
return 'move'
|
||||
|
||||
bmv1 = self.rfcontext.new2D_vert_mouse()
|
||||
if not bmv1:
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
if self.nearest_edge:
|
||||
if bmv0 in self.nearest_edge.verts:
|
||||
# selected vert already part of edge; split
|
||||
bme0,bmv2 = self.nearest_edge.split()
|
||||
bmv1.merge(bmv2)
|
||||
self.rfcontext.select(bmv1)
|
||||
else:
|
||||
bme0,bmv2 = self.nearest_edge.split()
|
||||
bmv1.merge(bmv2)
|
||||
bmf = next(iter(bmv0.shared_faces(bmv1)), None)
|
||||
if bmf:
|
||||
if not bmv0.share_edge(bmv1):
|
||||
bmf.split(bmv0, bmv1)
|
||||
if not bmv0.share_face(bmv1):
|
||||
bme = self.rfcontext.new_edge((bmv0, bmv1))
|
||||
self.rfcontext.select(bme)
|
||||
self.rfcontext.select(bmv1)
|
||||
else:
|
||||
bme = self.rfcontext.new_edge((bmv0, bmv1))
|
||||
self.rfcontext.select(bme)
|
||||
|
||||
elif self.next_state == 'vert-edge-vert':
|
||||
if self.nearest_vert:
|
||||
bmv1 = self.nearest_vert
|
||||
else:
|
||||
bmv1 = self.rfcontext.new2D_vert_mouse()
|
||||
if not bmv1:
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
if bmv0 == bmv1:
|
||||
return 'main'
|
||||
bme = bmv0.shared_edge(bmv1) or self.rfcontext.new_edge((bmv0, bmv1))
|
||||
self.rfcontext.select(bmv1)
|
||||
|
||||
else:
|
||||
return 'main'
|
||||
|
||||
self.mousedown = self.actions.mousedown
|
||||
xy = self.rfcontext.Point_to_Point2D(bmv1.co)
|
||||
if not xy:
|
||||
# dprint('Could not insert: ' + str(bmv1.co))
|
||||
pass
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
self.prep_move(
|
||||
bmverts=[bmv1],
|
||||
action_confirm=(lambda: self.actions.released('insert')),
|
||||
)
|
||||
return 'move'
|
||||
|
||||
if self.next_state == 'edge-face':
|
||||
bme,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
|
||||
if not bme: return
|
||||
bmv0,bmv1 = bme.verts
|
||||
|
||||
if self.nearest_vert and not self.nearest_vert.select:
|
||||
bmv2 = self.nearest_vert
|
||||
bmf = self.rfcontext.new_face([bmv0, bmv1, bmv2])
|
||||
self.rfcontext.clean_duplicate_bmedges(bmv2)
|
||||
else:
|
||||
bmv2 = self.rfcontext.new2D_vert_mouse()
|
||||
if not bmv2:
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
bmf = self.rfcontext.new_face([bmv0, bmv1, bmv2])
|
||||
|
||||
if bmf: self.rfcontext.select(bmf)
|
||||
self.mousedown = self.actions.mousedown
|
||||
xy = self.rfcontext.Point_to_Point2D(bmv2.co)
|
||||
if not xy:
|
||||
# dprint('Could not insert: ' + str(bmv2.co))
|
||||
pass
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
self.prep_move(
|
||||
bmverts=[bmv2],
|
||||
action_confirm=(lambda: self.actions.released('insert')),
|
||||
)
|
||||
return 'move'
|
||||
|
||||
if self.next_state == 'edge-quad':
|
||||
xy0,xy1,xy2,xy3 = self._get_edge_quad_verts()
|
||||
if xy0 is None or xy1 is None or xy2 is None or xy3 is None: return
|
||||
# a Desmos construction of how this works: https://www.desmos.com/geometry/bmmx206thi
|
||||
e0,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
|
||||
if not e0: return
|
||||
bmv0,bmv1 = e0.verts
|
||||
|
||||
bmv2,_ = self.rfcontext.nearest2D_vert(point=xy2, verts=self.vis_verts, max_dist=options['polypen merge dist'])
|
||||
if not bmv2: bmv2 = self.rfcontext.new2D_vert_point(xy2)
|
||||
bmv3,_ = self.rfcontext.nearest2D_vert(point=xy3, verts=self.vis_verts, max_dist=options['polypen merge dist'])
|
||||
if not bmv3: bmv3 = self.rfcontext.new2D_vert_point(xy3)
|
||||
if not bmv2 or not bmv3:
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
e1 = bmv2.shared_edge(bmv3)
|
||||
if not e1: e1 = self.rfcontext.new_edge([bmv2, bmv3])
|
||||
self.rfcontext.new_face([bmv0, bmv1, bmv2, bmv3])
|
||||
bmes = [bmv1.shared_edge(bmv2), bmv0.shared_edge(bmv3), bmv2.shared_edge(bmv3)]
|
||||
self.rfcontext.select(bmes, subparts=False)
|
||||
self.mousedown = self.actions.mousedown
|
||||
self.prep_move(
|
||||
bmverts=[bmv2, bmv3],
|
||||
action_confirm=(lambda: self.actions.released('insert')),
|
||||
)
|
||||
return 'move'
|
||||
|
||||
if self.next_state == 'edge-quad-snap':
|
||||
e0,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
|
||||
e1 = self.nearest_edge
|
||||
if not e0 or not e1: return
|
||||
bmv0,bmv1 = e0.verts
|
||||
bmv2,bmv3 = e1.verts
|
||||
p0,p1 = self.rfcontext.Point_to_Point2D(bmv0.co),self.rfcontext.Point_to_Point2D(bmv1.co)
|
||||
p2,p3 = self.rfcontext.Point_to_Point2D(bmv2.co),self.rfcontext.Point_to_Point2D(bmv3.co)
|
||||
if intersect2d_segment_segment(p1, p2, p3, p0): bmv2,bmv3 = bmv3,bmv2
|
||||
# if e0.vector2D(self.rfcontext.Point_to_Point2D).dot(e1.vector2D(self.rfcontext.Point_to_Point2D)) > 0:
|
||||
# bmv2,bmv3 = bmv3,bmv2
|
||||
self.rfcontext.new_face([bmv0, bmv1, bmv2, bmv3])
|
||||
# select all non-manifold edges that share vertex with e1
|
||||
bmes = [e for e in bmv2.link_edges + bmv3.link_edges if not e.is_manifold and not e.share_face(e1)]
|
||||
if not bmes:
|
||||
bmes = [bmv1.shared_edge(bmv2), bmv0.shared_edge(bmv3)]
|
||||
self.rfcontext.select(bmes, subparts=False)
|
||||
return 'main'
|
||||
|
||||
if self.next_state == 'tri-quad':
|
||||
hit_pos = self.actions.hit_pos
|
||||
if not hit_pos:
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
if not self.sel_edges:
|
||||
return 'main'
|
||||
bme0,_ = self.rfcontext.nearest2D_edge(edges=self.sel_edges)
|
||||
if not bme0: return
|
||||
bmv0,bmv2 = bme0.verts
|
||||
bme1,bmv1 = bme0.split()
|
||||
bme0.select = True
|
||||
bme1.select = True
|
||||
self.rfcontext.select(bmv1.link_edges)
|
||||
if self.nearest_vert and not self.nearest_vert.select:
|
||||
self.nearest_vert.merge(bmv1)
|
||||
bmv1 = self.nearest_vert
|
||||
self.rfcontext.clean_duplicate_bmedges(bmv1)
|
||||
for bme in bmv1.link_edges: bme.select &= len(bme.link_faces)==1
|
||||
bme01,bme12 = bmv0.shared_edge(bmv1),bmv1.shared_edge(bmv2)
|
||||
if len(bme01.link_faces) == 1: bme01.select = True
|
||||
if len(bme12.link_faces) == 1: bme12.select = True
|
||||
else:
|
||||
bmv1.co = hit_pos
|
||||
self.mousedown = self.actions.mousedown
|
||||
self.rfcontext.select(bmv1, only=False)
|
||||
xy = self.rfcontext.Point_to_Point2D(bmv1.co)
|
||||
if not xy:
|
||||
# dprint('Could not insert: ' + str(bmv1.co))
|
||||
pass
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
self.prep_move(
|
||||
bmverts=[bmv1],
|
||||
action_confirm=(lambda: self.actions.released('insert')),
|
||||
)
|
||||
return 'move'
|
||||
|
||||
nearest_edge,d = self.rfcontext.nearest2D_edge(edges=self.vis_edges)
|
||||
bmv = self.rfcontext.new2D_vert_mouse()
|
||||
if not bmv:
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
if d is not None and d < self.rfcontext.drawing.scale(options['polypen insert dist']):
|
||||
bme0,bmv2 = nearest_edge.split()
|
||||
bmv.merge(bmv2)
|
||||
self.rfcontext.select(bmv)
|
||||
self.mousedown = self.actions.mousedown
|
||||
xy = self.rfcontext.Point_to_Point2D(bmv.co)
|
||||
if not xy:
|
||||
# dprint('Could not insert: ' + str(bmv.co))
|
||||
pass
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
self.prep_move(
|
||||
bmverts=[bmv],
|
||||
action_confirm=(lambda: self.actions.released('insert')),
|
||||
)
|
||||
return 'move'
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<details id="polypen-options">
|
||||
<summary id="polypen-summary-label">PolyPen</summary>
|
||||
<div class="contents">
|
||||
<div class="collection">
|
||||
<h1>Automerge</h1>
|
||||
<div class="contents">
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['polypen automerge']''')" title="If enabled, grabbed vertices automatically merged with nearby vertices">
|
||||
Enable Automerge
|
||||
</label>
|
||||
<div class="labeled-input-text">
|
||||
<label for="polypen-merge-distance">Merge distance</label>
|
||||
<input id="polypen-merge-distance" type="number" value="BoundInt( '''options['polypen merge dist'] ''')" title="Pixel distance for merging and snapping">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="polypen-insert-distance">Insert distance</label>
|
||||
<input id="polypen-insert-distance" type="number" value="BoundInt( '''options['polypen insert dist'] ''')" title="Pixel distance for inserting into existing geometry">
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Insert Mode</h1>
|
||||
<div class="contents" id="polypen-insert-modes">
|
||||
<label class="half-size">
|
||||
Tri/Quad
|
||||
<input type="radio" value="Tri/Quad" checked="BoundString('''options['polypen insert mode']''')" name="polypen-insert-mode" on_input="self.update_insert_mode()" title="Inserting alternates between Triangles and Quads">
|
||||
</label>
|
||||
<label class="half-size">
|
||||
<input type="radio" value="Quad-Only" checked="BoundString('''options['polypen insert mode']''')" name="polypen-insert-mode" on_input="self.update_insert_mode()" title="Inserting Quads only">
|
||||
Quad-Only
|
||||
</label>
|
||||
<br> <!-- this is a hack to make Tri-Only radio below size correctly -->
|
||||
<label class="half-size">
|
||||
<input type="radio" value="Tri-Only" checked="BoundString('''options['polypen insert mode']''')" name="polypen-insert-mode" on_input="self.update_insert_mode()" title="Inserting Triangles only">
|
||||
Tri-Only
|
||||
</label>
|
||||
<label class="half-size">
|
||||
<input type="radio" value="Edge-Only" checked="BoundString('''options['polypen insert mode']''')" name="polypen-insert-mode" on_input="self.update_insert_mode()" title="Inserting Edges only">
|
||||
Edge-Only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@@ -0,0 +1,660 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import math
|
||||
import time
|
||||
import random
|
||||
|
||||
import bpy
|
||||
import gpu
|
||||
|
||||
from mathutils import Matrix, Vector
|
||||
from mathutils.geometry import intersect_point_tri_2d, intersect_point_tri_2d
|
||||
|
||||
from ..rftool import RFTool
|
||||
from ...addon_common.common.decorators import timed_call
|
||||
from ...addon_common.common import gpustate
|
||||
|
||||
|
||||
################################################################################################
|
||||
# following imports must happen *after* the above class, because each subclass depends on
|
||||
# above class to be defined
|
||||
|
||||
from .polystrips_ops import PolyStrips_Ops
|
||||
from .polystrips_props import PolyStrips_Props
|
||||
from .polystrips_utils import (
|
||||
RFTool_PolyStrips_Strip,
|
||||
hash_face_pair,
|
||||
crawl_strip,
|
||||
is_boundaryvert, is_boundaryedge,
|
||||
process_stroke_filter, process_stroke_source,
|
||||
process_stroke_get_next, process_stroke_get_marks,
|
||||
mark_info,
|
||||
)
|
||||
|
||||
from ...addon_common.common.bezier import CubicBezierSpline, CubicBezier
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.debug import dprint
|
||||
from ...addon_common.common.drawing import Drawing, Cursors, DrawCallbacks
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.maths import Vec2D, Point, rotate2D, Direction2D, Point2D, RelPoint2D
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.utils import iter_pairs
|
||||
|
||||
from ...config.options import options, themes
|
||||
|
||||
from ..rfwidgets.rfwidget_brushstroke import RFWidget_BrushStroke_Factory
|
||||
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
|
||||
from ..rfwidgets.rfwidget_hidden import RFWidget_Hidden_Factory
|
||||
|
||||
from ...addon_common.common.boundvar import BoundBool, BoundInt, BoundFloat, BoundString
|
||||
|
||||
|
||||
class PolyStrips(RFTool, PolyStrips_Props, PolyStrips_Ops):
|
||||
name = 'PolyStrips'
|
||||
description = 'Create and edit strips of quads'
|
||||
icon = 'polystrips-icon.png'
|
||||
help = 'polystrips.md'
|
||||
shortcut = 'polystrips tool'
|
||||
statusbar = '{{insert}} Insert strip of quads\t{{brush radius}} Brush size\t{{action}} Grab selection\t{{increase count}} Increase segments\t{{decrease count}} Decrease segments'
|
||||
ui_config = 'polystrips_options.html'
|
||||
|
||||
RFWidget_Default = RFWidget_Default_Factory.create()
|
||||
RFWidget_Move = RFWidget_Default_Factory.create(cursor='HAND')
|
||||
RFWidget_Hidden = RFWidget_Hidden_Factory.create()
|
||||
RFWidget_BrushStroke = RFWidget_BrushStroke_Factory.create(
|
||||
'PolyStrips stroke',
|
||||
BoundInt('''options['polystrips radius']''', min_value=1),
|
||||
outer_border_color=themes['polystrips']
|
||||
)
|
||||
|
||||
@RFTool.on_init
|
||||
def init(self):
|
||||
self.rfwidgets = {
|
||||
'default': self.RFWidget_Default(self),
|
||||
'brushstroke': self.RFWidget_BrushStroke(self),
|
||||
'move': self.RFWidget_Move(self),
|
||||
'hidden': self.RFWidget_Hidden(self),
|
||||
}
|
||||
self.rfwidget = None
|
||||
|
||||
@RFTool.on_reset
|
||||
def reset(self):
|
||||
self.strips = []
|
||||
self.strip_pts = []
|
||||
self.hovering_strips = set()
|
||||
self.hovering_handles = []
|
||||
self.hovering_sel_face = None
|
||||
self.sel_cbpts = []
|
||||
self.stroke_cbs = CubicBezierSpline()
|
||||
self.clear_count_data()
|
||||
|
||||
@RFTool.on_target_change
|
||||
# @profiler.function
|
||||
def update_target(self, force=False):
|
||||
if not force and self._fsm.state in {'move handle', 'rotate', 'scale'}: return
|
||||
|
||||
self.strips = []
|
||||
self._var_cut_count.disabled = True
|
||||
|
||||
# get selected quads
|
||||
bmquads = set(bmf for bmf in self.rfcontext.get_selected_faces() if len(bmf.verts) == 4)
|
||||
if not bmquads: return
|
||||
|
||||
# find junctions at corners
|
||||
junctions = set()
|
||||
for bmf in bmquads:
|
||||
# skip if in middle of a selection
|
||||
if not any(is_boundaryvert(bmv, bmquads) for bmv in bmf.verts): continue
|
||||
# skip if in middle of possible strip
|
||||
edge0,edge1,edge2,edge3 = [is_boundaryedge(bme, bmquads) for bme in bmf.edges]
|
||||
if (edge0 or edge2) and not (edge1 or edge3): continue
|
||||
if (edge1 or edge3) and not (edge0 or edge2): continue
|
||||
junctions.add(bmf)
|
||||
|
||||
# find junctions that might be in middle of strip but are ends to other strips
|
||||
boundaries = set((bme,bmf) for bmf in bmquads for bme in bmf.edges if is_boundaryedge(bme, bmquads))
|
||||
while boundaries:
|
||||
bme,bmf = boundaries.pop()
|
||||
for bme_ in bmf.neighbor_edges(bme):
|
||||
strip = crawl_strip(bmf, bme_, bmquads, junctions)
|
||||
if strip is None: continue
|
||||
junctions.add(strip[-1])
|
||||
|
||||
# find strips between junctions
|
||||
touched = set()
|
||||
for bmf0 in junctions:
|
||||
bme0,bme1,bme2,bme3 = bmf0.edges
|
||||
edge0,edge1,edge2,edge3 = [is_boundaryedge(bme, bmquads) for bme in bmf0.edges]
|
||||
|
||||
def add_strip(bme):
|
||||
strip = crawl_strip(bmf0, bme, bmquads, junctions)
|
||||
if not strip:
|
||||
return
|
||||
bmf1 = strip[-1]
|
||||
if len(strip) > 1 and hash_face_pair(bmf0, bmf1) not in touched:
|
||||
touched.add(hash_face_pair(bmf0,bmf1))
|
||||
touched.add(hash_face_pair(bmf1,bmf0))
|
||||
self.strips.append(RFTool_PolyStrips_Strip(strip))
|
||||
|
||||
if not edge0: add_strip(bme0)
|
||||
if not edge1: add_strip(bme1)
|
||||
if not edge2: add_strip(bme2)
|
||||
if not edge3: add_strip(bme3)
|
||||
if options['polystrips max strips'] and len(self.strips) > options['polystrips max strips']:
|
||||
self.strips = []
|
||||
break
|
||||
|
||||
self.update_strip_viz()
|
||||
if len(self.strips) == 1:
|
||||
self._var_cut_count.set(len(self.strips[0]))
|
||||
self._var_cut_count.disabled = False
|
||||
|
||||
if self.rfcontext.get_last_action() != 'change segment count':
|
||||
self.setup_change_count()
|
||||
|
||||
# @profiler.function
|
||||
def update_strip_viz(self):
|
||||
self.strip_pts = [[strip.curve.eval(i/10) for i in range(10+1)] for strip in self.strips]
|
||||
|
||||
@FSM.on_state('main')
|
||||
def main(self):
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
mouse = self.actions.mouse
|
||||
|
||||
if not self.actions.using('action', ignoredrag=True):
|
||||
# only update while not pressing action, because action includes drag, and
|
||||
# the artist might move mouse off selected edge before drag kicks in!
|
||||
self.hovering_sel_face,_ = self.rfcontext.accel_nearest2D_face(max_dist=options['action dist'], selected_only=True)
|
||||
|
||||
self.hovering_handles.clear()
|
||||
self.hovering_strips.clear()
|
||||
for strip in self.strips:
|
||||
for i,cbpt in enumerate(strip.curve):
|
||||
v = Point_to_Point2D(cbpt)
|
||||
if v is None: continue
|
||||
if (mouse - v).length > self.drawing.scale(options['select dist']): continue
|
||||
# do not filter out non-visible handles, because otherwise
|
||||
# they might not be movable if they are inside the model
|
||||
self.hovering_handles.append(cbpt)
|
||||
self.hovering_strips.add(strip)
|
||||
|
||||
if self.actions.using_onlymods('insert'):
|
||||
self.set_widget('brushstroke')
|
||||
elif self.hovering_handles:
|
||||
self.set_widget('move')
|
||||
elif self.hovering_sel_face:
|
||||
self.set_widget('move')
|
||||
else:
|
||||
self.set_widget('default')
|
||||
|
||||
if self.handle_inactive_passthrough(): return
|
||||
|
||||
# handle edits
|
||||
if self.hovering_handles:
|
||||
if self.actions.pressed('action'):
|
||||
return 'move handle'
|
||||
if self.actions.pressed('action alt0'):
|
||||
return 'rotate'
|
||||
if self.actions.pressed('action alt1'):
|
||||
return 'scale'
|
||||
|
||||
if self.hovering_sel_face:
|
||||
if self.actions.pressed('action', unpress=False):
|
||||
return 'move all'
|
||||
|
||||
if self.actions.pressed('grab', unpress=False):
|
||||
return 'move all'
|
||||
|
||||
if self.actions.pressed('increase count'):
|
||||
self.change_count(delta=1)
|
||||
return
|
||||
|
||||
if self.actions.pressed('decrease count'):
|
||||
self.change_count(delta=-1)
|
||||
return
|
||||
|
||||
if self.actions.pressed({'select path add'}):
|
||||
return self.rfcontext.select_path(
|
||||
{'face'},
|
||||
kwargs_select={'supparts': False},
|
||||
)
|
||||
|
||||
if self.actions.pressed({'select paint', 'select paint add'}, unpress=False):
|
||||
sel_only = self.actions.pressed('select paint')
|
||||
return self.rfcontext.setup_smart_selection_painting(
|
||||
{'face'},
|
||||
use_select_tool=True,
|
||||
selecting=not sel_only,
|
||||
deselect_all=sel_only,
|
||||
# fn_filter_bmelem=self.filter_edge_selection,
|
||||
kwargs_select={'supparts': False},
|
||||
kwargs_deselect={'subparts': False},
|
||||
)
|
||||
|
||||
if self.actions.pressed({'select single', 'select single add'}, unpress=False):
|
||||
sel_only = self.actions.pressed('select single')
|
||||
self.actions.unpress()
|
||||
bmf,_ = self.rfcontext.accel_nearest2D_face(max_dist=options['select dist'])
|
||||
if not sel_only and not bmf: return
|
||||
self.rfcontext.undo_push('select')
|
||||
if sel_only: self.rfcontext.deselect_all()
|
||||
if not bmf: return
|
||||
if bmf.select: self.rfcontext.deselect(bmf, subparts=False)
|
||||
else: self.rfcontext.select(bmf, supparts=False, only=sel_only)
|
||||
return
|
||||
|
||||
|
||||
@FSM.on_state('move handle', 'can enter')
|
||||
def movehandle_canenter(self):
|
||||
return len(self.hovering_handles) > 0
|
||||
|
||||
@FSM.on_state('move handle', 'enter')
|
||||
def movehandle_enter(self):
|
||||
self.sel_cbpts = []
|
||||
self.mod_strips = set()
|
||||
|
||||
cbpts = list(self.hovering_handles)
|
||||
self.mod_strips |= self.hovering_strips
|
||||
for strip in self.strips:
|
||||
p0,p1,p2,p3 = strip.curve.points()
|
||||
if p0 in cbpts and p1 not in cbpts:
|
||||
cbpts.append(p1)
|
||||
self.mod_strips.add(strip)
|
||||
if p3 in cbpts and p2 not in cbpts:
|
||||
cbpts.append(p2)
|
||||
self.mod_strips.add(strip)
|
||||
|
||||
for strip in self.mod_strips: strip.capture_edges()
|
||||
inners = [ p for strip in self.strips for p in strip.curve.points()[1:3] ]
|
||||
|
||||
self.sel_cbpts = [(cbpt, cbpt in inners, Point(cbpt), self.rfcontext.Point_to_Point2D(cbpt)) for cbpt in cbpts]
|
||||
self.mousedown = self.actions.mouse
|
||||
self.move_done_pressed = 'confirm'
|
||||
self.move_done_released = 'action'
|
||||
self.move_cancelled = 'cancel'
|
||||
self.rfcontext.undo_push('manipulate bezier')
|
||||
self.set_widget('hidden' if options['hide cursor on tweak'] else 'move')
|
||||
|
||||
self._timer = self.actions.start_timer(120.0)
|
||||
self.rfcontext.split_target_visualization(verts=self.rfcontext.get_selected_verts())
|
||||
self.rfcontext.set_accel_defer(True)
|
||||
|
||||
|
||||
@FSM.on_state('move handle')
|
||||
def movehandle(self):
|
||||
if self.actions.pressed(self.move_done_pressed):
|
||||
return 'main'
|
||||
if self.actions.released(self.move_done_released):
|
||||
return 'main'
|
||||
if self.actions.pressed(self.move_cancelled):
|
||||
self.rfcontext.undo_cancel()
|
||||
self.actions.unuse(self.move_done_released, ignoremods=True, ignoremulti=True)
|
||||
return 'main'
|
||||
|
||||
if self.actions.mousemove or not self.actions.mousemove_prev: return
|
||||
|
||||
delta = Vec2D(self.actions.mouse - self.mousedown)
|
||||
up,rt,fw = self.rfcontext.Vec_up(),self.rfcontext.Vec_right(),self.rfcontext.Vec_forward()
|
||||
for cbpt,inner,oco,oco2D in self.sel_cbpts:
|
||||
nco2D = oco2D + delta
|
||||
if not inner:
|
||||
xyz,_,_,_ = self.rfcontext.raycast_sources_Point2D(nco2D)
|
||||
if xyz: cbpt.xyz = xyz
|
||||
else:
|
||||
ov = self.rfcontext.Point2D_to_Vec(oco2D)
|
||||
nr = self.rfcontext.Point2D_to_Ray(nco2D)
|
||||
od = self.rfcontext.Point_to_depth(oco)
|
||||
cbpt.xyz = nr.eval(od / ov.dot(nr.d))
|
||||
|
||||
for strip in self.hovering_strips:
|
||||
strip.update(self.rfcontext.nearest_sources_Point, self.rfcontext.raycast_sources_Point, self.rfcontext.update_face_normal)
|
||||
|
||||
self.update_strip_viz()
|
||||
self.rfcontext.dirty()
|
||||
|
||||
@FSM.on_state('move handle', 'exit')
|
||||
def movehandle_exit(self):
|
||||
self._timer.done()
|
||||
self.rfcontext.clear_split_target_visualization()
|
||||
self.rfcontext.set_accel_defer(False)
|
||||
self.update_target(force=True)
|
||||
tag_redraw_all('PolyStrips done moving handles')
|
||||
|
||||
|
||||
@FSM.on_state('rotate', 'can enter')
|
||||
def rotate_canenter(self):
|
||||
if not self.hovering_handles: return False
|
||||
|
||||
self.sel_cbpts = []
|
||||
self.mod_strips = set()
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
|
||||
# find hovered inner point, the corresponding outer point and its face
|
||||
innerP,outerP,outerF = None,None,None
|
||||
for strip in self.strips:
|
||||
bmf0,bmf1 = strip.end_faces()
|
||||
p0,p1,p2,p3 = strip.curve.points()
|
||||
if p1 in self.hovering_handles: innerP,outerP,outerF = p1,p0,bmf0
|
||||
if p2 in self.hovering_handles: innerP,outerP,outerF = p2,p3,bmf1
|
||||
if not innerP or not outerP or not outerF: return False
|
||||
|
||||
# scan through all selected strips and collect all inner points next to outerP
|
||||
for strip in self.strips:
|
||||
bmf0,bmf3 = strip.end_faces()
|
||||
if outerF != bmf0 and outerF != bmf3: continue
|
||||
p0,p1,p2,p3 = strip.curve.points()
|
||||
if outerF == bmf0: self.sel_cbpts.append( (p1, Point(p1), Point_to_Point2D(p1)) )
|
||||
else: self.sel_cbpts.append( (p2, Point(p2), Point_to_Point2D(p2)) )
|
||||
self.mod_strips.add(strip)
|
||||
self.rotate_about = Point_to_Point2D(outerP)
|
||||
if not self.rotate_about: return False
|
||||
|
||||
@FSM.on_state('rotate', 'enter')
|
||||
def rotate_enter(self):
|
||||
for strip in self.mod_strips: strip.capture_edges()
|
||||
|
||||
self.mousedown = self.actions.mouse
|
||||
self.move_done_pressed = 'confirm'
|
||||
self.move_done_released = 'action alt0'
|
||||
self.move_cancelled = 'cancel'
|
||||
self.rfcontext.undo_push('rotate')
|
||||
self.set_widget('hidden' if options['hide cursor on tweak'] else 'move')
|
||||
|
||||
self._timer = self.actions.start_timer(120.0)
|
||||
self.rfcontext.split_target_visualization(verts=self.rfcontext.get_selected_verts())
|
||||
self.rfcontext.set_accel_defer(True)
|
||||
|
||||
@FSM.on_state('rotate')
|
||||
# @profiler.function
|
||||
def rotate(self):
|
||||
if not self.rotate_about: return 'main'
|
||||
if self.actions.pressed(self.move_done_pressed):
|
||||
return 'main'
|
||||
if self.actions.released(self.move_done_released):
|
||||
return 'main'
|
||||
if self.actions.pressed(self.move_cancelled):
|
||||
self.rfcontext.undo_cancel()
|
||||
self.actions.unuse(self.move_done_released, ignoremods=True, ignoremulti=True)
|
||||
return 'main'
|
||||
|
||||
if self.actions.mousemove or not self.actions.mousemove_prev: return
|
||||
|
||||
prev_diff = self.mousedown - self.rotate_about
|
||||
prev_rot = math.atan2(prev_diff.x, prev_diff.y)
|
||||
cur_diff = self.actions.mouse - self.rotate_about
|
||||
cur_rot = math.atan2(cur_diff.x, cur_diff.y)
|
||||
angle = prev_rot - cur_rot
|
||||
|
||||
for cbpt,oco,oco2D in self.sel_cbpts:
|
||||
xy = rotate2D(oco2D, angle, origin=self.rotate_about)
|
||||
xyz,_,_,_ = self.rfcontext.raycast_sources_Point2D(xy)
|
||||
if xyz: cbpt.xyz = xyz
|
||||
|
||||
for strip in self.mod_strips:
|
||||
strip.update(self.rfcontext.nearest_sources_Point, self.rfcontext.raycast_sources_Point, self.rfcontext.update_face_normal)
|
||||
|
||||
self.update_strip_viz()
|
||||
self.rfcontext.dirty()
|
||||
|
||||
@FSM.on_state('rotate', 'exit')
|
||||
def rotate_exit(self):
|
||||
self._timer.done()
|
||||
self.rfcontext.set_accel_defer(False)
|
||||
self.rfcontext.clear_split_target_visualization()
|
||||
self.update_target(force=True)
|
||||
|
||||
|
||||
|
||||
@FSM.on_state('scale', 'can enter')
|
||||
# @profiler.function
|
||||
def scale_canenter(self):
|
||||
if not self.hovering_handles: return False
|
||||
|
||||
self.mod_strips = set()
|
||||
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
innerP,outerP,outerF = None,None,None
|
||||
for strip in self.strips:
|
||||
bmf0,bmf1 = strip.end_faces()
|
||||
p0,p1,p2,p3 = strip.curve.points()
|
||||
if p1 in self.hovering_handles: innerP,outerP,outerF = p1,p0,bmf0
|
||||
if p2 in self.hovering_handles: innerP,outerP,outerF = p2,p3,bmf1
|
||||
if not innerP or not outerP or not outerF: return False
|
||||
|
||||
self.scale_strips = []
|
||||
for strip in self.strips:
|
||||
bmf0,bmf1 = strip.end_faces()
|
||||
if bmf0 == outerF:
|
||||
self.scale_strips.append((strip, 1))
|
||||
self.mod_strips.add(strip)
|
||||
if bmf1 == outerF:
|
||||
self.scale_strips.append((strip, 2))
|
||||
self.mod_strips.add(strip)
|
||||
|
||||
for strip in self.mod_strips: strip.capture_edges()
|
||||
|
||||
if not self.scale_strips: return False
|
||||
|
||||
self.scale_from = Point_to_Point2D(outerP)
|
||||
|
||||
@FSM.on_state('scale', 'enter')
|
||||
def scale_enter(self):
|
||||
self.mousedown = self.actions.mouse
|
||||
self.rfcontext.undo_push('scale')
|
||||
self.move_done_pressed = None
|
||||
self.move_done_released = 'action'
|
||||
self.move_cancelled = 'cancel'
|
||||
|
||||
falloff = options['polystrips scale falloff']
|
||||
|
||||
self.scale_bmf = {}
|
||||
self.scale_bmv = {}
|
||||
for strip,iinner in self.scale_strips:
|
||||
iend = 0 if iinner == 1 else 3
|
||||
s0,s1 = (1,0) if iend == 0 else (0,1)
|
||||
l = len(strip.bmf_strip)
|
||||
for ibmf,bmf in enumerate(strip.bmf_strip):
|
||||
if bmf in self.scale_bmf: continue
|
||||
p = ibmf/(l-1)
|
||||
s = (s0 + (s1-s0) * p) ** falloff
|
||||
self.scale_bmf[bmf] = s
|
||||
for bmf in self.scale_bmf.keys():
|
||||
c = bmf.center()
|
||||
s = self.scale_bmf[bmf]
|
||||
for bmv in bmf.verts:
|
||||
if bmv not in self.scale_bmv:
|
||||
self.scale_bmv[bmv] = []
|
||||
self.scale_bmv[bmv] += [(c, bmv.co-c, s)]
|
||||
|
||||
self.set_widget('hidden' if options['hide cursor on tweak'] else 'default') # None
|
||||
|
||||
self._timer = self.actions.start_timer(120.0)
|
||||
self.rfcontext.split_target_visualization(verts=self.rfcontext.get_selected_verts())
|
||||
self.rfcontext.set_accel_defer(True)
|
||||
|
||||
|
||||
@FSM.on_state('scale')
|
||||
# @profiler.function
|
||||
def scale(self):
|
||||
if self.actions.pressed(self.move_done_pressed):
|
||||
return 'main'
|
||||
if self.actions.released(self.move_done_released, ignoremods=True):
|
||||
return 'main'
|
||||
if self.actions.pressed(self.move_cancelled):
|
||||
self.rfcontext.undo_cancel()
|
||||
self.actions.unuse(self.move_done_released, ignoremods=True, ignoremulti=True)
|
||||
return 'main'
|
||||
|
||||
if self.actions.mousemove or not self.actions.mousemove_prev: return
|
||||
|
||||
vec0 = self.mousedown - self.scale_from
|
||||
vec1 = self.actions.mouse - self.scale_from
|
||||
scale = vec1.length / vec0.length
|
||||
|
||||
snap2D_vert = self.rfcontext.snap2D_vert
|
||||
snap_vert = self.rfcontext.snap_vert
|
||||
for bmv in self.scale_bmv.keys():
|
||||
l = self.scale_bmv[bmv]
|
||||
n = Vector()
|
||||
for c,v,sc in l:
|
||||
n += c + v * max(0, 1 + (scale-1) * sc)
|
||||
bmv.co = n / len(l)
|
||||
snap_vert(bmv)
|
||||
|
||||
self.rfcontext.dirty()
|
||||
|
||||
@FSM.on_state('scale', 'exit')
|
||||
def scale_exit(self):
|
||||
self._timer.done()
|
||||
self.rfcontext.set_accel_defer(False)
|
||||
self.rfcontext.clear_split_target_visualization()
|
||||
self.update_target(force=True)
|
||||
|
||||
|
||||
@FSM.on_state('move all', 'can enter')
|
||||
# @profiler.function
|
||||
def moveall_canenter(self):
|
||||
bmfaces = self.rfcontext.get_selected_faces()
|
||||
if not bmfaces: return False
|
||||
bmverts = set(bmv for bmf in bmfaces for bmv in bmf.verts)
|
||||
self.bmverts = [(bmv, self.rfcontext.Point_to_Point2D(bmv.co)) for bmv in bmverts]
|
||||
|
||||
@FSM.on_state('move all', 'enter')
|
||||
def moveall_enter(self):
|
||||
lmb_drag = self.actions.using('action')
|
||||
self.actions.unpress()
|
||||
self.rfcontext.undo_push('move grabbed')
|
||||
self.moveall_opts = {
|
||||
'mousedown': self.actions.mouse,
|
||||
'move_done_pressed': None if lmb_drag else 'confirm',
|
||||
'move_done_released': 'action' if lmb_drag else None,
|
||||
'move_cancelled': 'cancel',
|
||||
'timer': self.actions.start_timer(120.0),
|
||||
}
|
||||
self.rfcontext.split_target_visualization_selected()
|
||||
self.rfcontext.set_accel_defer(True)
|
||||
self.set_widget('hidden' if options['hide cursor on tweak'] else 'default') # None
|
||||
|
||||
@FSM.on_state('move all')
|
||||
# @profiler.function
|
||||
def moveall(self):
|
||||
opts = self.moveall_opts
|
||||
|
||||
if self.actions.pressed(opts['move_done_pressed']):
|
||||
return 'main'
|
||||
if self.actions.released(opts['move_done_released']):
|
||||
return 'main'
|
||||
if self.actions.pressed(opts['move_cancelled']):
|
||||
self.rfcontext.undo_cancel()
|
||||
self.actions.unuse(opts['move_done_released'], ignoremods=True, ignoremulti=True)
|
||||
return 'main'
|
||||
|
||||
if self.actions.mousemove or not self.actions.mousemove_prev: return
|
||||
|
||||
delta = Vec2D(self.actions.mouse - opts['mousedown'])
|
||||
set2D_vert = self.rfcontext.set2D_vert
|
||||
for bmv,xy in self.bmverts:
|
||||
if not bmv.is_valid: continue
|
||||
set2D_vert(bmv, xy + delta)
|
||||
self.rfcontext.update_verts_faces(v for v,_ in self.bmverts)
|
||||
self.rfcontext.dirty()
|
||||
#self.update()
|
||||
|
||||
@FSM.on_state('move all', 'exit')
|
||||
def moveall_exit(self):
|
||||
self.moveall_opts['timer'].done()
|
||||
self.rfcontext.set_accel_defer(False)
|
||||
self.rfcontext.clear_split_target_visualization()
|
||||
self.update_target(force=True)
|
||||
|
||||
|
||||
|
||||
@DrawCallbacks.on_draw('post3d')
|
||||
@FSM.onlyinstate({'main', 'move handle', 'rotate', 'scale'})
|
||||
def draw_post3d_spline(self):
|
||||
if not self.strips: return
|
||||
|
||||
strips = self.strips
|
||||
hov_strips = self.hovering_strips
|
||||
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
|
||||
def is_visible(v):
|
||||
return True # self.rfcontext.is_visible(v, None)
|
||||
|
||||
def draw(alphamult, hov_alphamult, hover):
|
||||
nonlocal strips
|
||||
|
||||
if not hover: hov_alphamult = alphamult
|
||||
|
||||
size_outer = options['polystrips handle outer size']
|
||||
size_inner = options['polystrips handle inner size']
|
||||
border_outer = options['polystrips handle border']
|
||||
border_inner = options['polystrips handle border']
|
||||
|
||||
gpustate.blend('ALPHA')
|
||||
|
||||
# draw outer-inner lines
|
||||
pts = [Point_to_Point2D(p) for strip in strips for p in strip.curve.points()]
|
||||
self.rfcontext.drawing.draw2D_lines(pts, (1,1,1,0.45), width=2)
|
||||
|
||||
# draw junction handles (outer control points of curve)
|
||||
faces_drawn = set() # keep track of faces, so don't draw same handles 2+ times
|
||||
pts_outer,pts_inner = [],[]
|
||||
for strip in strips:
|
||||
bmf0,bmf1 = strip.end_faces()
|
||||
p0,p1,p2,p3 = strip.curve.points()
|
||||
if bmf0 not in faces_drawn:
|
||||
if is_visible(p0): pts_outer += [Point_to_Point2D(p0)]
|
||||
faces_drawn.add(bmf0)
|
||||
if bmf1 not in faces_drawn:
|
||||
if is_visible(p3): pts_outer += [Point_to_Point2D(p3)]
|
||||
faces_drawn.add(bmf1)
|
||||
if is_visible(p1): pts_inner += [Point_to_Point2D(p1)]
|
||||
if is_visible(p2): pts_inner += [Point_to_Point2D(p2)]
|
||||
pts_outer = [p for p in pts_outer if p]
|
||||
pts_inner = [p for p in pts_inner if p]
|
||||
self.rfcontext.drawing.draw2D_points(pts_outer, (1.00,1.00,1.00,1.0), radius=size_outer, border=border_outer, borderColor=(0.00,0.00,0.00,0.5))
|
||||
self.rfcontext.drawing.draw2D_points(pts_inner, (0.25,0.25,0.25,0.8), radius=size_inner, border=border_inner, borderColor=(0.75,0.75,0.75,0.4))
|
||||
|
||||
gpustate.blend('ALPHA')
|
||||
gpustate.depth_test('NONE')
|
||||
gpustate.depth_mask(False)
|
||||
draw(1.0, 1.0, False)
|
||||
gpustate.depth_mask(True)
|
||||
gpustate.depth_test('LESS_EQUAL')
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate({'main', 'move handle', 'rotate', 'scale'})
|
||||
def draw_post2d(self):
|
||||
self.rfcontext.drawing.set_font_size(12)
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
text_draw2D = self.rfcontext.drawing.text_draw2D
|
||||
|
||||
for strip in self.strips:
|
||||
strip = [f for f in strip if f.is_valid]
|
||||
c = len(strip)
|
||||
vs = [Point_to_Point2D(f.center()) for f in strip]
|
||||
vs = [Vec2D(v) for v in vs if v]
|
||||
if not vs: continue
|
||||
ctr = sum(vs, Vec2D((0,0))) / len(vs)
|
||||
text_draw2D('%d' % c, ctr+Vec2D((2,14)), color=(1,1,0,1), dropshadow=(0,0,0,0.5))
|
||||
@@ -0,0 +1,413 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import math
|
||||
from mathutils import Vector
|
||||
from mathutils.geometry import intersect_point_tri_2d, intersect_point_tri_2d
|
||||
|
||||
from ..rftool import RFTool
|
||||
|
||||
from ...addon_common.common.bezier import CubicBezierSpline, CubicBezier
|
||||
from ...addon_common.common.debug import dprint
|
||||
from ...addon_common.common.drawing import Drawing, Cursors
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.utils import iter_pairs
|
||||
|
||||
from ..rfwidget import RFWidget
|
||||
from .polystrips_utils import (
|
||||
RFTool_PolyStrips_Strip,
|
||||
hash_face_pair,
|
||||
crawl_strip,
|
||||
is_boundaryvert, is_boundaryedge,
|
||||
process_stroke_filter, process_stroke_source,
|
||||
process_stroke_get_next, process_stroke_get_marks,
|
||||
mark_info,
|
||||
)
|
||||
|
||||
|
||||
class PolyStrips_Ops:
|
||||
@RFWidget.on_action('PolyStrips stroke')
|
||||
@RFTool.dirty_when_done
|
||||
def new_brushstroke(self):
|
||||
# called when artist finishes a stroke
|
||||
radius = self.rfwidgets['brushstroke'].radius
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
Point2D_to_Ray = self.rfcontext.Point2D_to_Ray
|
||||
nearest_sources_Point = self.rfcontext.nearest_sources_Point
|
||||
raycast = lambda p: self.rfcontext.raycast_sources_Point2D(p, correct_mirror=False)
|
||||
vis_verts = self.rfcontext.visible_verts()
|
||||
vis_edges = self.rfcontext.visible_edges(verts=vis_verts)
|
||||
vis_faces = self.rfcontext.visible_faces(verts=vis_verts)
|
||||
vis_edges2D,vis_faces2D = [],[]
|
||||
new_geom = []
|
||||
|
||||
def add_edge(bme): vis_edges2D.append((bme, [Point_to_Point2D(bmv.co) for bmv in bme.verts]))
|
||||
def add_face(bmf): vis_faces2D.append((bmf, [Point_to_Point2D(bmv.co) for bmv in bmf.verts]))
|
||||
|
||||
def intersect_face(pt):
|
||||
# todo: rewrite! inefficient!
|
||||
nonlocal vis_faces2D
|
||||
for f,vs in vis_faces2D:
|
||||
v0 = vs[0]
|
||||
for v1,v2 in iter_pairs(vs[1:], False):
|
||||
if intersect_point_tri_2d(pt, v0, v1, v2): return f
|
||||
return None
|
||||
|
||||
def snap_point(p2D_init, dist):
|
||||
p = raycast(p2D_init)[0]
|
||||
if p: return p
|
||||
# did not hit source, so find nearest point on source to where the point would have been
|
||||
r = Point2D_to_Ray(p2D_init)
|
||||
p = r.eval(dist)
|
||||
return nearest_sources_Point(p)[0]
|
||||
|
||||
def create_edge(center, tangent, mult, perpendicular):
|
||||
nonlocal new_geom
|
||||
rad = radius
|
||||
hd,mmult = None,mult
|
||||
while not hd:
|
||||
p = center + tangent * mmult
|
||||
hp,hn,hi,hd = raycast(p)
|
||||
mmult -= 0.1
|
||||
p0 = snap_point(center + tangent * mult + perpendicular * rad, hd)
|
||||
p1 = snap_point(center + tangent * mult - perpendicular * rad, hd)
|
||||
bmv0 = self.rfcontext.new_vert_point(p0)
|
||||
bmv1 = self.rfcontext.new_vert_point(p1)
|
||||
if not bmv0 or not bmv1: return None
|
||||
bme = self.rfcontext.new_edge([bmv0,bmv1])
|
||||
add_edge(bme)
|
||||
new_geom += [bme]
|
||||
return bme
|
||||
|
||||
def create_face_in_l(bme0, bme1):
|
||||
'''
|
||||
creates a face strip between edges that share a vertex (L-shaped)
|
||||
'''
|
||||
# find shared vert
|
||||
nonlocal new_geom
|
||||
bmv1 = bme0.shared_vert(bme1)
|
||||
bmv0,bmv2 = bme0.other_vert(bmv1),bme1.other_vert(bmv1)
|
||||
c0,c1,c2 = bmv0.co,bmv1.co,bmv2.co
|
||||
c3 = nearest_sources_Point(c1 + (c0-c1) + (c2-c1))[0]
|
||||
bmv3 = self.rfcontext.new_vert_point(c3)
|
||||
bmf = self.rfcontext.new_face([bmv0,bmv1,bmv2,bmv3])
|
||||
# TODO: what if bmf is None??
|
||||
bme2,bme3 = bmv2.shared_edge(bmv3),bmv3.shared_edge(bmv0)
|
||||
add_face(bmf)
|
||||
add_edge(bme2)
|
||||
add_edge(bme3)
|
||||
new_geom += [bme2,bme3,bmf]
|
||||
return bmf
|
||||
|
||||
def create_face(bme01, bme23):
|
||||
# 0 3 0--3
|
||||
# | | -> | |
|
||||
# 1 2 1--2
|
||||
nonlocal new_geom
|
||||
if not bme01 or not bme23: return None
|
||||
if bme01.share_vert(bme23): return create_face_in_l(bme01, bme23)
|
||||
bmv0,bmv1 = bme01.verts
|
||||
bmv2,bmv3 = bme23.verts
|
||||
if bme01.vector().dot(bme23.vector()) > 0: bmv2,bmv3 = bmv3,bmv2
|
||||
bmf = self.rfcontext.new_face([bmv0,bmv1,bmv2,bmv3])
|
||||
# TODO: what if bmf is None?
|
||||
bme12 = bmv1.shared_edge(bmv2)
|
||||
bme30 = bmv3.shared_edge(bmv0)
|
||||
add_edge(bme12)
|
||||
add_edge(bme30)
|
||||
add_face(bmf)
|
||||
new_geom += [bme12, bme30, bmf]
|
||||
return bmf
|
||||
|
||||
|
||||
for bme in vis_edges: add_edge(bme)
|
||||
for bmf in vis_faces: add_face(bmf)
|
||||
|
||||
self.rfcontext.undo_push('stroke')
|
||||
|
||||
stroke = list(self.rfwidgets['brushstroke'].stroke2D)
|
||||
# filter stroke down where each pt is at least 1px away to eliminate local wiggling
|
||||
stroke = process_stroke_filter(stroke)
|
||||
stroke = process_stroke_source(stroke, self.rfcontext.raycast_sources_Point2D, self.rfcontext.is_point_on_mirrored_side)
|
||||
|
||||
from_edge = None
|
||||
while len(stroke) > 2:
|
||||
# get stroke segment to work on
|
||||
from_edge,cstroke,to_edge,cont,stroke = process_stroke_get_next(stroke, from_edge, vis_edges2D)
|
||||
|
||||
# filter cstroke to contain unique points
|
||||
while True:
|
||||
ncstroke = [cstroke[0]]
|
||||
for cp,np in iter_pairs(cstroke,False):
|
||||
if (cp-np).length > 0: ncstroke += [np]
|
||||
if len(cstroke) == len(ncstroke): break
|
||||
cstroke = ncstroke
|
||||
|
||||
# discard stroke segment if it lies in a face
|
||||
if intersect_face(cstroke[1]):
|
||||
# dprint('stroke is on face (1)')
|
||||
pass
|
||||
from_edge = to_edge
|
||||
continue
|
||||
if intersect_face(cstroke[-2]):
|
||||
# dprint('stroke is on face (-2)')
|
||||
pass
|
||||
from_edge = to_edge
|
||||
continue
|
||||
|
||||
# estimate length of stroke (used with radius to determine num of quads)
|
||||
stroke_len = sum((p0-p1).length for (p0,p1) in iter_pairs(cstroke,False))
|
||||
|
||||
# marks start and end at center of quad, and alternate with
|
||||
# edge and face, each approx radius distance apart
|
||||
# +---+---+---+---+---+
|
||||
# | | | | | |
|
||||
# +---+---+---+---+---+
|
||||
# ^ ^ ^ ^ ^ ^ ^ ^ ^ <-----marks (nmarks: 9, nquads: 5)
|
||||
# ^ ^ ^ ^ ^ ^ ^ ^ <- if from_edge not None
|
||||
# ^ ^ ^ ^ ^ ^ ^ ^ <- if to_edge not None
|
||||
# ^ ^ ^ ^ ^ ^ ^ <- if from_edge and to_edge are not None
|
||||
# mark counts:
|
||||
# min marks = 3 [ | ] (2 quads)
|
||||
# marks = 5 [ | | ] (3 quads)
|
||||
# marks = 7 [ | | | ] (4 quads)
|
||||
# marks must be odd
|
||||
# if from_edge is not None, then stroke starts at edge
|
||||
# if to_edge is not None, then stroke ends at edge
|
||||
markoff0 = 0 if from_edge is None else 1
|
||||
markoff1 = 0 if to_edge is None else 1
|
||||
nmarks = int(math.ceil(stroke_len / radius)) # approx num of marks
|
||||
nmarks = nmarks + (1 - ((nmarks+markoff0+markoff1) % 2)) # make sure odd count
|
||||
nmarks = max(nmarks, 3-markoff0-markoff1) # min marks = 3
|
||||
nmarks = max(nmarks, 2) # fix div by 0 :(
|
||||
# marks are found at dists along stroke
|
||||
at_dists = [stroke_len*i/(nmarks-1) for i in range(nmarks)]
|
||||
# compute marks
|
||||
marks = process_stroke_get_marks(cstroke, at_dists)
|
||||
|
||||
# compute number of quads
|
||||
nquads = int(((nmarks-markoff0-markoff1) + 1) / 2)
|
||||
# dprint('nmarks = %d, markoff0 = %d, markoff1 = %d, nquads = %d' % (nmarks, markoff0, markoff1, nquads))
|
||||
pass
|
||||
|
||||
if from_edge and to_edge and nquads == 1:
|
||||
if from_edge.share_vert(to_edge):
|
||||
create_face_in_l(from_edge, to_edge)
|
||||
continue
|
||||
|
||||
# add edges
|
||||
if from_edge is None:
|
||||
# create from_edge
|
||||
# dprint('creating from_edge')
|
||||
pass
|
||||
pt,tn,pe = mark_info(marks, 0)
|
||||
from_edge = create_edge(pt, -tn, radius, pe)
|
||||
else:
|
||||
new_geom += list(from_edge.link_faces)
|
||||
|
||||
if to_edge is None:
|
||||
# dprint('creating to_edge')
|
||||
pass
|
||||
pt,tn,pe = mark_info(marks, nmarks-1)
|
||||
to_edge = create_edge(pt, tn, radius, pe)
|
||||
else:
|
||||
new_geom += list(to_edge.link_faces)
|
||||
|
||||
for iquad in range(1, nquads):
|
||||
#print('creating edge')
|
||||
pt,tn,pe = mark_info(marks, iquad*2+markoff0-1)
|
||||
bme = create_edge(pt, tn, 0.0, pe)
|
||||
bmf = create_face(from_edge, bme)
|
||||
from_edge = bme
|
||||
bmf = create_face(from_edge, to_edge)
|
||||
|
||||
from_edge = to_edge if cont else None
|
||||
|
||||
self.rfcontext.select(new_geom, supparts=False)
|
||||
|
||||
def clear_count_data(self):
|
||||
self.count_data = {
|
||||
'delta': 0,
|
||||
'delta adjust': 0,
|
||||
'update fns': [],
|
||||
'nfaces': [],
|
||||
'splines': [],
|
||||
'points': [],
|
||||
}
|
||||
|
||||
def setup_change_count(self):
|
||||
self.clear_count_data()
|
||||
|
||||
def process(bmfs, bmes):
|
||||
# find edge strips
|
||||
strip0,strip1 = [bmes[0].verts[0]], [bmes[0].verts[1]]
|
||||
edges0,edges1 = [],[]
|
||||
for bmf,bme0 in zip(bmfs,bmes):
|
||||
bme1,bme2 = bmf.neighbor_edges(bme0)
|
||||
if strip0[-1] in bme2.verts: bme1,bme2 = bme2,bme1
|
||||
strip0.append(bme1.other_vert(strip0[-1]))
|
||||
strip1.append(bme2.other_vert(strip1[-1]))
|
||||
edges0.append(bme1)
|
||||
edges1.append(bme2)
|
||||
if len(strip0) < 3: return
|
||||
pts0,pts1 = [v.co for v in strip0],[v.co for v in strip1]
|
||||
lengths0,lengths1 = [e.length for e in edges0],[e.length for e in edges1]
|
||||
#length0,length1 = sum(lengths0),sum(lengths1)
|
||||
|
||||
max_error = min(min(lengths0),min(lengths1)) / 100.0 # arbitrary!
|
||||
spline0 = CubicBezierSpline.create_from_points([pts0], max_error, min_count_split=3)
|
||||
spline1 = CubicBezierSpline.create_from_points([pts1], max_error, min_count_split=3)
|
||||
spline0.tessellate_uniform(lambda a,b: (a-b).length, 50)
|
||||
spline1.tessellate_uniform(lambda a,b: (a-b).length, 50)
|
||||
len0,len1 = len(spline0), len(spline1)
|
||||
self.count_data['splines'] += [spline0, spline1]
|
||||
self.count_data['points'] += pts0 + pts1
|
||||
|
||||
ccount = len(bmfs)
|
||||
|
||||
nfaces = []
|
||||
nedges = []
|
||||
nverts = [bmv for bme in bmes[1:-1] for bmv in bme.verts]
|
||||
|
||||
def fn(count=None, delta=None):
|
||||
nonlocal nverts
|
||||
if count is not None: ncount = count
|
||||
else: ncount = ccount + delta
|
||||
if ncount < 1:
|
||||
self.count_data['delta adjust'] = max(self.count_data['delta adjust'], 1 - ncount)
|
||||
ncount = 1
|
||||
ncount = max(1, ncount)
|
||||
|
||||
# approximate ts along each strip
|
||||
def approx_ts(spline_len, lengths):
|
||||
nonlocal ncount,ccount
|
||||
accum_ts_old = [0]
|
||||
for l in lengths: accum_ts_old.append(accum_ts_old[-1] + l)
|
||||
total_ts_old = sum(lengths)
|
||||
ts_old = [Vector((i, t / total_ts_old, 0)) for i,t in enumerate(accum_ts_old)]
|
||||
spline_ts_old = CubicBezierSpline.create_from_points([ts_old], 0.01)
|
||||
spline_ts_old_len = len(spline_ts_old)
|
||||
ts = [spline_len * spline_ts_old.eval(spline_ts_old_len * i / ncount).y for i in range(ncount+1)]
|
||||
return ts
|
||||
ts0 = approx_ts(len0, lengths0)
|
||||
ts1 = approx_ts(len1, lengths1)
|
||||
|
||||
if not nverts:
|
||||
#self.rfcontext.delete_faces(nfaces)
|
||||
self.rfcontext.delete_edges(nedges)
|
||||
else:
|
||||
self.rfcontext.delete_verts(nverts)
|
||||
nverts.clear()
|
||||
nedges.clear()
|
||||
nfaces.clear()
|
||||
# self.rfcontext.delete_edges(edges0 + edges1 + bmes[1:-1])
|
||||
|
||||
def new_vert(p):
|
||||
v = self.rfcontext.new_vert_point(p)
|
||||
nverts.append(v)
|
||||
return v
|
||||
verts0 = strip0[:1] + [new_vert(spline0.eval(t)) for t in ts0[1:-1]] + strip0[-1:]
|
||||
verts1 = strip1[:1] + [new_vert(spline1.eval(t)) for t in ts1[1:-1]] + strip1[-1:]
|
||||
|
||||
for (v00,v01),(v10,v11) in zip(iter_pairs(verts0,False), iter_pairs(verts1,False)):
|
||||
nf = self.rfcontext.new_face([v00,v01,v11,v10])
|
||||
assert nf
|
||||
self.count_data['nfaces'].append(nf)
|
||||
nfaces.append(nf)
|
||||
for (v00, v01) in iter_pairs(verts0, False):
|
||||
nedges.append(v00.shared_edge(v01))
|
||||
for (v10, v11) in iter_pairs(verts1, False):
|
||||
nedges.append(v10.shared_edge(v11))
|
||||
|
||||
self.count_data['update fns'].append(fn)
|
||||
|
||||
|
||||
|
||||
# find selected faces that are not part of strips
|
||||
# [ | | | | | | | ]
|
||||
# |O| |O| <- with either of these selected, split into two
|
||||
# [ | | | ]
|
||||
|
||||
rffaces = self.rfcontext.get_selected_faces()
|
||||
bmquads = [bmf for bmf in rffaces if len(bmf.verts) == 4]
|
||||
bmquads = [bmq for bmq in bmquads if not any(bmq in strip for strip in self.strips)]
|
||||
for bmf in bmquads:
|
||||
bmes = list(bmf.edges)
|
||||
boundaries = [len(bme.link_faces) == 2 for bme in bmf.edges]
|
||||
if (boundaries[0] or boundaries[2]) and not boundaries[1] and not boundaries[3]:
|
||||
process([bmf], [bmes[0],bmes[2]])
|
||||
continue
|
||||
if (boundaries[1] or boundaries[3]) and not boundaries[0] and not boundaries[2]:
|
||||
process([bmf], [bmes[1],bmes[3]])
|
||||
continue
|
||||
|
||||
# find boundary portions of each strip
|
||||
# TODO: what if there are multiple boundary portions??
|
||||
# [ | |O| | ]
|
||||
# |O| <-
|
||||
# |O| <- only working on this part of strip
|
||||
# |O| <-
|
||||
# |O| | ]
|
||||
# [ | |O| | ]
|
||||
|
||||
for strip in self.strips:
|
||||
bmfs,bmes = [],[]
|
||||
bme0 = strip.bme0
|
||||
for bmf in strip:
|
||||
bme2 = bmf.opposite_edge(bme0)
|
||||
bme1,bme3 = bmf.neighbor_edges(bme0)
|
||||
if len(bme1.link_faces) == 1 and len(bme3.link_faces) == 1:
|
||||
bmes.append(bme0)
|
||||
bmfs.append(bmf)
|
||||
else:
|
||||
# if we've already seen a portion of the strip that can be modified, break!
|
||||
if bmfs:
|
||||
bmes.append(bme0)
|
||||
break
|
||||
bme0 = bme2
|
||||
else:
|
||||
bmes.append(bme0)
|
||||
if not bmfs: continue
|
||||
process(bmfs, bmes)
|
||||
|
||||
@RFTool.dirty_when_done
|
||||
def change_count(self, *, count=None, delta=None):
|
||||
'''
|
||||
find parallel strips of boundary edges, fit curve to verts of strips, then
|
||||
recompute faces based on curves.
|
||||
|
||||
note: this op will only change counts along boundaries. otherwise, use loop cut
|
||||
'''
|
||||
self.rfcontext.undo_push('change segment count', repeatable=True)
|
||||
self.count_data['nfaces'].clear()
|
||||
self.count_data['delta adjust'] = 0
|
||||
if delta is not None:
|
||||
self.count_data['delta'] += delta
|
||||
delta = self.count_data['delta']
|
||||
for fn in self.count_data['update fns']:
|
||||
fn(count=count, delta=delta)
|
||||
if self.count_data['nfaces']:
|
||||
self.rfcontext.select(self.count_data['nfaces'], supparts=False, only=False)
|
||||
if delta is not None:
|
||||
self.count_data['delta'] += self.count_data['delta adjust']
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<details id="polystrips-options">
|
||||
<summary>PolyStrips</summary>
|
||||
<div class="contents">
|
||||
<div class="labeled-input-text">
|
||||
<label for="polystrips-cut-count">
|
||||
Cut Count
|
||||
</label>
|
||||
<input type="text" value="self._var_cut_count" id="polystrips-cut-count" title="Number of cuts along selected strip">
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="polystrips-scale-falloff">
|
||||
Scale Falloff
|
||||
</label>
|
||||
<input id="polystrips-scale-falloff" type="text" value="BoundFloat('''options['polystrips scale falloff']''', min_value=0.25, max_value=4.0)" title="Controls how quickly control point scaling falls off">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@@ -0,0 +1,53 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import os
|
||||
import re
|
||||
import math
|
||||
from itertools import chain
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix
|
||||
|
||||
from ..rftool import RFTool
|
||||
|
||||
from ...addon_common.common.boundvar import BoundInt, BoundFloat
|
||||
from ...addon_common.common.utils import delay_exec
|
||||
from ...config.options import options
|
||||
|
||||
|
||||
class PolyStrips_Props:
|
||||
@RFTool.on_init
|
||||
def init_props(self):
|
||||
self._var_cut_count = BoundInt('''self.var_cut_count''', min_value=2, max_value=500)
|
||||
self._var_scale_falloff = BoundFloat('''options['polystrips scale falloff']''', min_value=0.25, max_value=4.0)
|
||||
|
||||
@property
|
||||
def var_cut_count(self):
|
||||
return getattr(self, '_var_cut_count_value', 0)
|
||||
@var_cut_count.setter
|
||||
def var_cut_count(self, v):
|
||||
if self.var_cut_count == v: return
|
||||
self._var_cut_count_value = v
|
||||
if self._var_cut_count.disabled: return
|
||||
self.rfcontext.undo_push('change segment count', repeatable=True)
|
||||
self.change_count(count=v)
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
|
||||
import bpy
|
||||
import math
|
||||
from mathutils import Vector, Matrix
|
||||
from mathutils.geometry import intersect_line_line_2d
|
||||
from ...addon_common.common.debug import dprint
|
||||
from ...addon_common.common.maths import Point,Point2D,Vec2D,Vec, Normal, clamp
|
||||
from ...addon_common.common.bezier import CubicBezierSpline, CubicBezier
|
||||
from ...addon_common.common.utils import iter_pairs
|
||||
|
||||
def is_boundaryedge(bme, only_bmfs):
|
||||
return len(set(bme.link_faces) & only_bmfs) == 1
|
||||
def is_boundaryvert(bmv, only_bmfs):
|
||||
return len(set(bmv.link_faces) - only_bmfs) > 0 or bmv.is_boundary
|
||||
|
||||
def crawl_strip(bmf0, bme0_2, only_bmfs, stop_bmfs, touched=None):
|
||||
#
|
||||
# *------*------*
|
||||
# ===> | bmf0 | bmf1 | ===>
|
||||
# *------*------*
|
||||
# ^ ^
|
||||
# bme0_2=bme1_0 / \ bme1_2
|
||||
#
|
||||
bmfs = [bmf for bmf in bme0_2.link_faces if bmf in only_bmfs and bmf != bmf0]
|
||||
if len(bmfs) != 1: return [bmf0]
|
||||
bmf1 = bmfs[0]
|
||||
# rotate bmedges so bme1_0 is where we came from, bme1_2 is where we are going
|
||||
bmf1_edges = bmf1.edges
|
||||
if bme0_2 == bmf1_edges[0]: bme1_0,bme1_1,bme1_2,bme1_3 = bmf1_edges
|
||||
elif bme0_2 == bmf1_edges[1]: bme1_3,bme1_0,bme1_1,bme1_2 = bmf1_edges
|
||||
elif bme0_2 == bmf1_edges[2]: bme1_2,bme1_3,bme1_0,bme1_1 = bmf1_edges
|
||||
elif bme0_2 == bmf1_edges[3]: bme1_1,bme1_2,bme1_3,bme1_0 = bmf1_edges
|
||||
else: assert False, 'Something very unexpected happened!'
|
||||
|
||||
if bmf1 not in only_bmfs: return [bmf0]
|
||||
if bmf1 in stop_bmfs: return [bmf0, bmf1]
|
||||
if touched and bmf1 in touched: return None
|
||||
if not touched: touched = set()
|
||||
touched.add(bmf0)
|
||||
next_part = crawl_strip(bmf1, bme1_2, only_bmfs, stop_bmfs, touched)
|
||||
if next_part is None: return None
|
||||
return [bmf0] + next_part
|
||||
|
||||
def strip_centers(strip):
|
||||
return [bmf.center() for bmf in strip]
|
||||
pts = []
|
||||
radius = 0
|
||||
for bmf in strip:
|
||||
bmvs = bmf.verts
|
||||
pts = [bmv.co for bmv in bmf.verts]
|
||||
v = Point.average(pts)
|
||||
r = sum((pt - v).length for pt in pts) / 4
|
||||
# r = ((pts[0] - pts[1]).length + (pts[1] - pts[2]).length + (pts[2] - pts[3]).length + (pts[3] - pts[0]).length) / 8
|
||||
if not pts: radius = r
|
||||
else: radius = max(radius, r)
|
||||
pts += [v]
|
||||
if False:
|
||||
tesspts = []
|
||||
tess_count = 2 if len(strip)>2 else 4
|
||||
for pt0,pt1 in zip(pts[:-1],pts[1:]):
|
||||
for i in range(tess_count):
|
||||
p = i / tess_count
|
||||
tesspts += [pt0 + (pt1-pt0)*p]
|
||||
pts = tesspts + [pts[-1]]
|
||||
return (pts, radius)
|
||||
|
||||
def hash_face_pair(bmf0, bmf1):
|
||||
return str(bmf0.__hash__()) + str(bmf1.__hash__())
|
||||
|
||||
|
||||
def process_stroke_filter(stroke, min_distance=1.0, max_distance=2.0):
|
||||
''' filter stroke to pts that are at least min_distance apart '''
|
||||
nstroke = stroke[:1]
|
||||
for p in stroke[1:]:
|
||||
v = p - nstroke[-1]
|
||||
l = v.length
|
||||
if l < min_distance: continue
|
||||
d = v / l
|
||||
while l > 0:
|
||||
q = nstroke[-1] + d * min(l, max_distance)
|
||||
nstroke.append(q)
|
||||
l -= max_distance
|
||||
return nstroke
|
||||
|
||||
def process_stroke_source(stroke, raycast, is_point_on_mirrored_side):
|
||||
''' filter out pts that don't hit source on non-mirrored side '''
|
||||
pts = [(pt, raycast(pt)[0]) for pt in stroke]
|
||||
return [pt for pt,p3d in pts if p3d and not is_point_on_mirrored_side(p3d)]
|
||||
|
||||
def process_stroke_split_at_crossings(stroke):
|
||||
strokes = []
|
||||
stroke = list(stroke)
|
||||
l = len(stroke)
|
||||
cstroke = [stroke.pop()]
|
||||
while stroke:
|
||||
if not stroke[-1]:
|
||||
strokes.append(cstroke)
|
||||
stroke.pop()
|
||||
cstroke = [stroke.pop()]
|
||||
continue
|
||||
p0,p1 = cstroke[-1],stroke[-1]
|
||||
# see if p0-p1 segment crosses any other segment
|
||||
for i in range(len(stroke)-3):
|
||||
q0,q1 = stroke[i+0],stroke[i+1]
|
||||
if q0 is None or q1 is None: continue
|
||||
p = intersect_line_line_2d(p0,p1, q0,q1)
|
||||
if not p: continue
|
||||
if (p-p0).length < 0.000001 or (p-p1).length < 0.000001: continue
|
||||
# intersection!
|
||||
strokes.append(cstroke + [p])
|
||||
cstroke = [p]
|
||||
# note: inserting None to indicate broken stroke
|
||||
stroke = stroke[:i+1] + [p,None,p] + stroke[i+1:]
|
||||
break
|
||||
else:
|
||||
# no intersections!
|
||||
cstroke.append(stroke.pop())
|
||||
if cstroke: strokes.append(cstroke)
|
||||
return strokes
|
||||
|
||||
def process_stroke_get_next(stroke, from_edge, edges2D):
|
||||
# returns the next chunk of stroke to be processed
|
||||
# stops at...
|
||||
# - discontinuity
|
||||
# - intersection with self
|
||||
# - intersection with edges (ignoring from_edge)
|
||||
# - "strong" corners
|
||||
|
||||
cstroke = []
|
||||
to_edge = None
|
||||
curve_distance, curve_threshold = 25.0, math.cos(60.0 * math.pi/180.0)
|
||||
discontinuity_distance = 10.0
|
||||
|
||||
def compute_cosangle_at_index(idx):
|
||||
nonlocal stroke
|
||||
if idx >= len(stroke): return 1.0
|
||||
p0 = stroke[idx]
|
||||
for iprev in range(idx-1, -1, -1):
|
||||
pprev = stroke[iprev]
|
||||
if (p0-pprev).length < curve_distance: continue
|
||||
break
|
||||
else:
|
||||
return 1.0
|
||||
for inext in range(idx+1, len(stroke)):
|
||||
pnext = stroke[inext]
|
||||
if (p0-pnext).length < curve_distance: continue
|
||||
break
|
||||
else:
|
||||
return 1.0
|
||||
dprev = (p0 - pprev).normalized()
|
||||
dnext = (pnext - p0).normalized()
|
||||
cosangle = dprev.dot(dnext)
|
||||
return cosangle
|
||||
|
||||
for i0 in range(1, len(stroke)-1):
|
||||
i1 = i0 + 1
|
||||
p0,p1 = stroke[i0],stroke[i1]
|
||||
|
||||
# check for discontinuity
|
||||
if (p0-p1).length > discontinuity_distance:
|
||||
# dprint('frag: %d %d %d' % (i0, len(stroke), len(stroke)-i1))
|
||||
pass
|
||||
return (from_edge, stroke[:i1], None, False, stroke[i1:])
|
||||
|
||||
# check for self-intersection
|
||||
for j0 in range(i0+3, len(stroke)-1):
|
||||
q0,q1 = stroke[j0],stroke[j0+1]
|
||||
p = intersect_line_line_2d(p0,p1, q0,q1)
|
||||
if not p: continue
|
||||
# dprint('self: %d %d %d' % (i0, len(stroke), len(stroke)-i1))
|
||||
pass
|
||||
return (from_edge, stroke[:i1], None, False, stroke[i1:])
|
||||
|
||||
# check for intersections with edges
|
||||
for bme,(q0,q1) in edges2D:
|
||||
if bme is from_edge: continue
|
||||
p = intersect_line_line_2d(p0,p1, q0,q1)
|
||||
if not p: continue
|
||||
# dprint('edge: %d %d %d' % (i0, len(stroke), len(stroke)-i1))
|
||||
pass
|
||||
return (from_edge, stroke[:i1], bme, True, stroke[i1:])
|
||||
|
||||
# check for strong angles
|
||||
cosangle = compute_cosangle_at_index(i0)
|
||||
if cosangle > curve_threshold: continue
|
||||
# found a strong angle, but there may be a stronger angle coming up...
|
||||
minangle = cosangle
|
||||
for i0_plus in range(i0+1, len(stroke)):
|
||||
p0_plus = stroke[i0_plus]
|
||||
if (p0-p0_plus).length > curve_distance: break
|
||||
minangle = min(compute_cosangle_at_index(i0_plus), minangle)
|
||||
if minangle < cosangle: break
|
||||
if minangle < cosangle: continue
|
||||
# dprint('bend: %d %d %d' % (i0, len(stroke), len(stroke)-i1))
|
||||
pass
|
||||
return (from_edge, stroke[:i1], None, False, stroke[i1:])
|
||||
|
||||
# dprint('full: %d %d' % (len(stroke), len(stroke)))
|
||||
pass
|
||||
return (from_edge, stroke, None, False, [])
|
||||
|
||||
def process_stroke_get_marks(stroke, at_dists):
|
||||
marks = []
|
||||
tot_dist = 0
|
||||
i_at_dists = 0
|
||||
i_stroke = 1
|
||||
cp = stroke[0]
|
||||
np = stroke[1]
|
||||
dist_to_np = (np-cp).length
|
||||
dir_to_np = (np-cp).normalized()
|
||||
|
||||
while len(marks) < len(at_dists):
|
||||
# can we go to np without passing next mark?
|
||||
dratio = (at_dists[i_at_dists] - tot_dist) / dist_to_np if dist_to_np > 0 else 1
|
||||
if dratio >= 1:
|
||||
tot_dist += dist_to_np
|
||||
i_stroke += 1
|
||||
if i_stroke == len(stroke): break
|
||||
cp,np = np,stroke[i_stroke]
|
||||
dist_to_np = (np - cp).length
|
||||
dir_to_np = (np - cp).normalized()
|
||||
continue
|
||||
dist_traveled = dist_to_np * dratio
|
||||
cp = cp + dir_to_np * dist_traveled
|
||||
marks.append(cp)
|
||||
dist_to_np -= dist_traveled
|
||||
tot_dist += dist_traveled
|
||||
i_at_dists += 1
|
||||
|
||||
while len(marks) < len(at_dists):
|
||||
marks.append(stroke[-1])
|
||||
|
||||
return marks
|
||||
|
||||
def mark_info(marks, imark):
|
||||
imark0 = max(imark-1, 0)
|
||||
imark1 = min(imark+1, len(marks)-1)
|
||||
#assert imark0!=imark1, '%d %d %d %d' % (marks, imark, imark0, imark1)
|
||||
tangent = (marks[imark1] - marks[imark0]).normalized()
|
||||
perpendicular = Vec2D((-tangent.y, tangent.x))
|
||||
return (marks[imark], tangent, perpendicular)
|
||||
|
||||
|
||||
class RFTool_PolyStrips_Strip:
|
||||
def __init__(self, bmf_strip):
|
||||
self.bmf_strip = bmf_strip
|
||||
self.recompute_curve()
|
||||
self.capture_edges()
|
||||
|
||||
def __len__(self): return len(self.bmf_strip)
|
||||
|
||||
def __iter__(self): return iter(self.bmf_strip)
|
||||
|
||||
def __getitem__(self, key): return self.bmf_strip[key]
|
||||
|
||||
def end_faces(self): return (self.bmf_strip[0], self.bmf_strip[-1])
|
||||
|
||||
def recompute_curve(self):
|
||||
pts = strip_centers(self.bmf_strip)
|
||||
self.curve = CubicBezier.create_from_points(pts)
|
||||
self.curve.tessellate_uniform(lambda p,q:(p-q).length, split=50)
|
||||
|
||||
def capture_edges(self):
|
||||
self.bmes = []
|
||||
bmes = [(bmf0.shared_edge(bmf1), Normal(bmf0.normal+bmf1.normal)) for bmf0,bmf1 in iter_pairs(self.bmf_strip, False)]
|
||||
self.bme0 = self.bmf_strip[0].opposite_edge(bmes[0][0])
|
||||
self.bme1 = self.bmf_strip[-1].opposite_edge(bmes[-1][0])
|
||||
if len(self.bme0.link_faces) == 1: bmes = [(self.bme0, self.bmf_strip[0].normal)] + bmes
|
||||
if len(self.bme1.link_faces) == 1: bmes = bmes + [(self.bme1, self.bmf_strip[-1].normal)]
|
||||
if any(not bme.is_valid for (bme,_) in bmes):
|
||||
# filter out invalid edges (see commit 88e4fde4)
|
||||
bmes = [(bme,norm) for (bme,norm) in bmes if bme.is_valid]
|
||||
for bme,norm in bmes:
|
||||
bmvs = bme.verts
|
||||
halfdiff = (bmvs[1].co - bmvs[0].co) / 2.0
|
||||
diffdir = halfdiff.normalized()
|
||||
center = bmvs[0].co + halfdiff
|
||||
|
||||
t = self.curve.approximate_t_at_point_tessellation(center, lambda p,q:(p-q).length)
|
||||
pos,der = self.curve.eval(t),self.curve.eval_derivative(t).normalized()
|
||||
|
||||
rad = halfdiff.length
|
||||
cross = der.cross(norm).normalized()
|
||||
off = center - pos
|
||||
off_cross,off_der,off_norm = cross.dot(off),der.dot(off),norm.dot(off)
|
||||
rot = math.acos(clamp(diffdir.dot(cross), -0.9999999, 0.9999999))
|
||||
if diffdir.dot(der) < 0: rot = -rot
|
||||
self.bmes += [(bme, t, rad, rot, off_cross, off_der, off_norm)]
|
||||
|
||||
def update(self, nearest_sources_Point, raycast_sources_Point, update_face_normal):
|
||||
self.curve.tessellate_uniform(lambda p,q:(p-q).length, split=50)
|
||||
length = self.curve.approximate_totlength_tessellation()
|
||||
for bme,t,rad,rot,off_cross,off_der,off_norm in self.bmes:
|
||||
pos,norm,_,_ = raycast_sources_Point(self.curve.eval(t))
|
||||
if not norm: continue
|
||||
der = self.curve.eval_derivative(t).normalized()
|
||||
cross = der.cross(norm).normalized()
|
||||
center = pos + der * off_der + cross * off_cross + norm * off_norm
|
||||
rotcross = (Matrix.Rotation(rot, 3, norm) @ cross).normalized()
|
||||
p0 = center - rotcross * rad
|
||||
p1 = center + rotcross * rad
|
||||
bmv0,bmv1 = bme.verts
|
||||
v0,n0,_,_ = raycast_sources_Point(p0)
|
||||
v1,n1,_,_ = raycast_sources_Point(p1)
|
||||
if not v0: v0,n0,_,_ = nearest_sources_Point(p0)
|
||||
if not v1: v1,n1,_,_ = nearest_sources_Point(p1)
|
||||
if v0: bmv0.co_normal = (v0, n0)
|
||||
if v1: bmv1.co_normal = (v1, n1)
|
||||
for bmf in self.bmf_strip:
|
||||
update_face_normal(bmf)
|
||||
@@ -0,0 +1,456 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import math
|
||||
import time
|
||||
from ..rftool import RFTool
|
||||
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
|
||||
from ..rfwidgets.rfwidget_brushfalloff import RFWidget_BrushFalloff_Factory
|
||||
|
||||
from ...addon_common.common.maths import (
|
||||
Vec, Vec2D,
|
||||
Point, Point2D,
|
||||
Direction,
|
||||
Color,
|
||||
closest_point_segment,
|
||||
)
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.boundvar import BoundBool, BoundInt, BoundFloat, BoundString
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.utils import iter_pairs, delay_exec
|
||||
from ...config.options import options, themes
|
||||
|
||||
|
||||
class Relax(RFTool):
|
||||
name = 'Relax'
|
||||
description = 'Relax the vertex positions to smooth out topology'
|
||||
icon = 'relax-icon.png'
|
||||
help = 'relax.md'
|
||||
shortcut = 'relax tool'
|
||||
quick_shortcut = 'relax quick'
|
||||
statusbar = '{{brush}} Relax\t{{brush alt}} Relax selection\t{{brush radius}} Brush size\t{{brush strength}} Brush strength\t{{brush falloff}} Brush falloff'
|
||||
ui_config = 'relax_options.html'
|
||||
|
||||
RFWidget_Default = RFWidget_Default_Factory.create()
|
||||
RFWidget_BrushFalloff = RFWidget_BrushFalloff_Factory.create(
|
||||
'Relax brush',
|
||||
BoundInt('''options['relax radius']''', min_value=1),
|
||||
BoundFloat('''options['relax falloff']''', min_value=0.00, max_value=100.0),
|
||||
BoundFloat('''options['relax strength']''', min_value=0.01, max_value=1.0),
|
||||
fill_color=themes['relax'],
|
||||
)
|
||||
|
||||
@RFTool.on_init
|
||||
def init(self):
|
||||
self.rfwidgets = {
|
||||
'default': self.RFWidget_Default(self),
|
||||
'brushstroke': self.RFWidget_BrushFalloff(self),
|
||||
}
|
||||
self.rfwidget = None
|
||||
|
||||
def reset_algorithm_options(self):
|
||||
options.reset(keys=[
|
||||
'relax steps',
|
||||
'relax force multiplier',
|
||||
'relax edge length',
|
||||
'relax face radius',
|
||||
'relax face sides',
|
||||
'relax face angles',
|
||||
'relax correct flipped faces',
|
||||
'relax straight edges',
|
||||
])
|
||||
|
||||
def disable_all_options(self):
|
||||
for key in [
|
||||
'relax edge length',
|
||||
'relax face radius',
|
||||
'relax face sides',
|
||||
'relax face angles',
|
||||
'relax correct flipped faces',
|
||||
'relax straight edges',
|
||||
]:
|
||||
options[key] = False
|
||||
|
||||
def reset_current_brush(self):
|
||||
options.reset(keys={'relax radius', 'relax falloff', 'relax strength'})
|
||||
self.document.body.getElementById(f'relax-current-radius').dirty(cause='copied preset to current brush')
|
||||
self.document.body.getElementById(f'relax-current-strength').dirty(cause='copied preset to current brush')
|
||||
self.document.body.getElementById(f'relax-current-falloff').dirty(cause='copied preset to current brush')
|
||||
|
||||
def update_preset_name(self, n):
|
||||
name = options[f'relax preset {n} name']
|
||||
self.document.body.getElementById(f'relax-preset-{n}-summary').innerText = f'Preset: {name}'
|
||||
|
||||
def copy_current_to_preset(self, n):
|
||||
options[f'relax preset {n} radius'] = options['relax radius']
|
||||
options[f'relax preset {n} strength'] = options['relax strength']
|
||||
options[f'relax preset {n} falloff'] = options['relax falloff']
|
||||
self.document.body.getElementById(f'relax-preset-{n}-radius').dirty(cause='copied current brush to preset')
|
||||
self.document.body.getElementById(f'relax-preset-{n}-strength').dirty(cause='copied current brush to preset')
|
||||
self.document.body.getElementById(f'relax-preset-{n}-falloff').dirty(cause='copied current brush to preset')
|
||||
|
||||
def copy_preset_to_current(self, n):
|
||||
options['relax radius'] = options[f'relax preset {n} radius']
|
||||
options['relax strength'] = options[f'relax preset {n} strength']
|
||||
options['relax falloff'] = options[f'relax preset {n} falloff']
|
||||
self.document.body.getElementById(f'relax-current-radius').dirty(cause='copied preset to current brush')
|
||||
self.document.body.getElementById(f'relax-current-strength').dirty(cause='copied preset to current brush')
|
||||
self.document.body.getElementById(f'relax-current-falloff').dirty(cause='copied preset to current brush')
|
||||
|
||||
@RFTool.on_ui_setup
|
||||
def ui(self):
|
||||
self.update_preset_name(1)
|
||||
self.update_preset_name(2)
|
||||
self.update_preset_name(3)
|
||||
self.update_preset_name(4)
|
||||
|
||||
@RFTool.on_reset
|
||||
def reset(self):
|
||||
self.sel_only = False
|
||||
|
||||
@FSM.on_state('main')
|
||||
def main(self):
|
||||
if self.actions.using_onlymods(['brush', 'brush alt', 'brush radius', 'brush falloff', 'brush strength']):
|
||||
self.set_widget('brushstroke')
|
||||
else:
|
||||
self.set_widget('default')
|
||||
|
||||
if self.rfcontext.actions.pressed(['brush', 'brush alt'], unpress=False):
|
||||
self.sel_only = self.rfcontext.actions.using('brush alt')
|
||||
self.rfcontext.actions.unpress()
|
||||
self.rfcontext.undo_push('relax')
|
||||
return 'relax'
|
||||
|
||||
if self.rfcontext.actions.pressed('pie menu alt0', unpress=False):
|
||||
def callback(option):
|
||||
if option is None: return
|
||||
self.copy_preset_to_current(option)
|
||||
self.rfcontext.show_pie_menu([
|
||||
(f'Preset: {options["relax preset 1 name"]}', 1),
|
||||
(f'Preset: {options["relax preset 2 name"]}', 2),
|
||||
(f'Preset: {options["relax preset 3 name"]}', 3),
|
||||
(f'Preset: {options["relax preset 4 name"]}', 4),
|
||||
], callback)
|
||||
return
|
||||
|
||||
# if self.rfcontext.actions.pressed('select single'):
|
||||
# self.rfcontext.undo_push('select')
|
||||
# self.rfcontext.deselect_all()
|
||||
# return 'select'
|
||||
|
||||
# if self.rfcontext.actions.pressed('select single add'):
|
||||
# face,_ = self.rfcontext.accel_nearest2D_face(max_dist=10)
|
||||
# if not face: return
|
||||
# if face.select:
|
||||
# self.mousedown = self.rfcontext.actions.mouse
|
||||
# return 'selectadd/deselect'
|
||||
# return 'select'
|
||||
|
||||
# if self.rfcontext.actions.pressed({'select smart', 'select smart add'}, unpress=False):
|
||||
# if self.rfcontext.actions.pressed('select smart'):
|
||||
# self.rfcontext.deselect_all()
|
||||
# self.rfcontext.actions.unpress()
|
||||
# edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=10)
|
||||
# if not edge: return
|
||||
# faces = set()
|
||||
# walk = {edge}
|
||||
# touched = set()
|
||||
# while walk:
|
||||
# edge = walk.pop()
|
||||
# if edge in touched: continue
|
||||
# touched.add(edge)
|
||||
# nfaces = set(f for f in edge.link_faces if f not in faces and len(f.edges) == 4)
|
||||
# walk |= {f.opposite_edge(edge) for f in nfaces}
|
||||
# faces |= nfaces
|
||||
# self.rfcontext.select(faces, only=False)
|
||||
# return
|
||||
|
||||
# @FSM.on_state('selectadd/deselect')
|
||||
# def selectadd_deselect(self):
|
||||
# if not self.rfcontext.actions.using(['select single','select single add']):
|
||||
# self.rfcontext.undo_push('deselect')
|
||||
# face,_ = self.rfcontext.accel_nearest2D_face()
|
||||
# if face and face.select: self.rfcontext.deselect(face)
|
||||
# return 'main'
|
||||
# delta = Vec2D(self.rfcontext.actions.mouse - self.mousedown)
|
||||
# if delta.length > self.drawing.scale(5):
|
||||
# self.rfcontext.undo_push('select add')
|
||||
# return 'select'
|
||||
|
||||
# @FSM.on_state('select')
|
||||
# def select(self):
|
||||
# if not self.rfcontext.actions.using(['select single','select single add']):
|
||||
# return 'main'
|
||||
# bmf,_ = self.rfcontext.accel_nearest2D_face(max_dist=10)
|
||||
# if not bmf or bmf.select: return
|
||||
# self.rfcontext.select(bmf, supparts=False, only=False)
|
||||
|
||||
@FSM.on_state('relax', 'enter')
|
||||
def relax_enter(self):
|
||||
self._time = time.time()
|
||||
|
||||
opt_mask_boundary = options['relax mask boundary']
|
||||
opt_mask_symmetry = options['relax mask symmetry']
|
||||
opt_mask_occluded = options['relax mask occluded']
|
||||
opt_mask_selected = options['relax mask selected']
|
||||
opt_steps = options['relax steps']
|
||||
opt_edge_length = options['relax edge length']
|
||||
opt_face_radius = options['relax face radius']
|
||||
opt_face_sides = options['relax face sides']
|
||||
opt_face_angles = options['relax face angles']
|
||||
opt_correct_flipped = options['relax correct flipped faces']
|
||||
opt_straight_edges = options['relax straight edges']
|
||||
opt_mult = options['relax force multiplier']
|
||||
|
||||
is_visible = self.rfcontext.gen_is_visible(occlusion_test_override=True)
|
||||
is_bmvert_hidden = lambda bmv: not is_visible(bmv.co, bmv.normal)
|
||||
|
||||
self._bmverts = []
|
||||
self._boundary = []
|
||||
for bmv in self.rfcontext.iter_verts():
|
||||
if self.sel_only and not bmv.select: continue
|
||||
if opt_mask_boundary == 'exclude' and bmv.is_on_boundary(): continue
|
||||
if opt_mask_symmetry == 'exclude' and bmv.is_on_symmetry_plane(): continue
|
||||
if opt_mask_occluded == 'exclude' and is_bmvert_hidden(bmv): continue
|
||||
if opt_mask_selected == 'exclude' and bmv.select: continue
|
||||
if opt_mask_selected == 'only' and not bmv.select: continue
|
||||
self._bmverts.append(bmv)
|
||||
|
||||
print(f'Relax {len(self._bmverts)} bmverts')
|
||||
|
||||
if opt_mask_boundary == 'slide':
|
||||
# find all boundary edges
|
||||
self._boundary = [(bme.verts[0].co, bme.verts[1].co) for bme in self.rfcontext.iter_edges() if not bme.is_manifold]
|
||||
|
||||
# print(f'Relaxing max of {len(self._bmverts)} bmverts')
|
||||
self._timer = self.actions.start_timer(120)
|
||||
self.rfcontext.split_target_visualization(verts=self._bmverts)
|
||||
|
||||
@FSM.on_state('relax', 'exit')
|
||||
def relax_exit(self):
|
||||
self.rfcontext.update_verts_faces(self._bmverts)
|
||||
self.rfcontext.clear_split_target_visualization()
|
||||
self._timer.done()
|
||||
|
||||
@FSM.on_state('relax')
|
||||
def relax(self):
|
||||
if self.rfcontext.actions.released(['brush','brush alt']):
|
||||
return 'main'
|
||||
if self.rfcontext.actions.pressed('cancel'):
|
||||
self.rfcontext.undo_cancel()
|
||||
self.actions.unuse('brush', ignoremods=True, ignoremulti=True)
|
||||
self.actions.unuse('brush alt', ignoremods=True, ignoremulti=True)
|
||||
return 'main'
|
||||
|
||||
@RFTool.on_new_frame
|
||||
@FSM.onlyinstate('relax')
|
||||
def relax_doit(self):
|
||||
st = time.time()
|
||||
|
||||
hit_pos = self.rfcontext.actions.hit_pos
|
||||
if not hit_pos: return
|
||||
|
||||
# collect data for smoothing
|
||||
radius = self.rfwidgets['brushstroke'].get_scaled_radius()
|
||||
nearest = self.rfcontext.nearest_verts_point(hit_pos, radius, bmverts=self._bmverts)
|
||||
verts,edges,faces,vert_strength = set(),set(),set(),dict()
|
||||
for bmv,d in nearest:
|
||||
verts.add(bmv)
|
||||
edges.update(bmv.link_edges)
|
||||
faces.update(bmv.link_faces)
|
||||
vert_strength[bmv] = self.rfwidgets['brushstroke'].get_strength_dist(d) / radius
|
||||
# self.rfcontext.select(verts)
|
||||
|
||||
if not verts or not edges: return
|
||||
vert_strength = vert_strength or {}
|
||||
|
||||
# gather options
|
||||
opt_mask_boundary = options['relax mask boundary']
|
||||
opt_mask_symmetry = options['relax mask symmetry']
|
||||
# opt_mask_occluded = options['relax mask hidden']
|
||||
# opt_mask_selected = options['relax mask selected']
|
||||
opt_steps = options['relax steps']
|
||||
opt_edge_length = options['relax edge length']
|
||||
opt_face_radius = options['relax face radius']
|
||||
opt_face_sides = options['relax face sides']
|
||||
opt_face_angles = options['relax face angles']
|
||||
opt_correct_flipped = options['relax correct flipped faces']
|
||||
opt_straight_edges = options['relax straight edges']
|
||||
opt_mult = options['relax force multiplier']
|
||||
|
||||
cur_time = time.time()
|
||||
time_delta = cur_time - self._time
|
||||
self._time = cur_time
|
||||
strength = (5.0 / opt_steps) * self.rfwidgets['brushstroke'].strength * time_delta
|
||||
radius = self.rfwidgets['brushstroke'].get_scaled_radius()
|
||||
|
||||
# capture all verts involved in relaxing
|
||||
chk_verts = set(verts)
|
||||
chk_verts.update(self.rfcontext.get_edges_verts(edges))
|
||||
chk_verts.update(self.rfcontext.get_faces_verts(faces))
|
||||
chk_edges = self.rfcontext.get_verts_link_edges(chk_verts)
|
||||
chk_faces = self.rfcontext.get_verts_link_faces(chk_verts)
|
||||
|
||||
displace = {}
|
||||
def reset_forces():
|
||||
nonlocal displace
|
||||
displace.clear()
|
||||
def add_force(bmv, f):
|
||||
nonlocal displace, verts, vert_strength
|
||||
if bmv not in verts or bmv not in vert_strength: return
|
||||
cur = displace[bmv] if bmv in displace else Vec((0,0,0))
|
||||
displace[bmv] = cur + f
|
||||
|
||||
def relax_2d():
|
||||
pass
|
||||
|
||||
def relax_3d():
|
||||
reset_forces()
|
||||
|
||||
# compute average edge length
|
||||
avg_edge_len = sum(bme.calc_length() for bme in edges) / len(edges)
|
||||
|
||||
# push edges closer to average edge length
|
||||
if opt_edge_length:
|
||||
for bme in chk_edges:
|
||||
if bme not in edges: continue
|
||||
bmv0,bmv1 = bme.verts
|
||||
vec = bme.vector()
|
||||
edge_len = vec.length
|
||||
f = vec * (0.1 * (avg_edge_len - edge_len) * strength) #/ edge_len
|
||||
add_force(bmv0, -f)
|
||||
add_force(bmv1, +f)
|
||||
|
||||
# push verts if neighboring faces seem flipped (still WiP!)
|
||||
if opt_correct_flipped:
|
||||
bmf_flipped = { bmf for bmf in chk_faces if bmf.is_flipped() }
|
||||
for bmf in bmf_flipped:
|
||||
# find a non-flipped neighboring face
|
||||
for bme in bmf.edges:
|
||||
bmfs = set(bme.link_faces)
|
||||
bmfs.discard(bmf)
|
||||
if len(bmfs) != 1: continue
|
||||
bmf_other = next(iter(bmfs))
|
||||
if bmf_other not in chk_faces: continue
|
||||
if bmf_other in bmf_flipped: continue
|
||||
# pull edge toward bmf_other center
|
||||
bmf_other_center = bmf_other.center()
|
||||
bme_center = bme.calc_center()
|
||||
vec = bmf_other_center - bme_center
|
||||
bmv0,bmv1 = bme.verts
|
||||
add_force(bmv0, vec * strength * 5)
|
||||
add_force(bmv1, vec * strength * 5)
|
||||
|
||||
# push verts to straighten edges (still WiP!)
|
||||
if opt_straight_edges:
|
||||
for bmv in chk_verts:
|
||||
if bmv.is_boundary: continue
|
||||
bmes = bmv.link_edges
|
||||
#if len(bmes) != 4: continue
|
||||
center = Point.average(bme.other_vert(bmv).co for bme in bmes)
|
||||
add_force(bmv, (center - bmv.co) * 0.1)
|
||||
|
||||
# attempt to "square" up the faces
|
||||
for bmf in chk_faces:
|
||||
if bmf not in faces: continue
|
||||
bmvs = bmf.verts
|
||||
cnt = len(bmvs)
|
||||
ctr = Point.average(bmv.co for bmv in bmvs)
|
||||
rels = [bmv.co - ctr for bmv in bmvs]
|
||||
|
||||
# push verts toward average dist from verts to face center
|
||||
if opt_face_radius:
|
||||
avg_rel_len = sum(rel.length for rel in rels) / cnt
|
||||
for rel, bmv in zip(rels, bmvs):
|
||||
rel_len = rel.length
|
||||
f = rel * ((avg_rel_len - rel_len) * strength * 2) #/ rel_len
|
||||
add_force(bmv, f)
|
||||
|
||||
# push verts toward equal edge lengths
|
||||
if opt_face_sides:
|
||||
avg_face_edge_len = sum(bme.length for bme in bmf.edges) / cnt
|
||||
for bme in bmf.edges:
|
||||
bmv0, bmv1 = bme.verts
|
||||
vec = bme.vector()
|
||||
edge_len = vec.length
|
||||
f = vec * ((avg_face_edge_len - edge_len) * strength) / edge_len
|
||||
add_force(bmv0, f * -0.5)
|
||||
add_force(bmv1, f * 0.5)
|
||||
|
||||
# push verts toward equal spread
|
||||
if opt_face_angles:
|
||||
avg_angle = 2.0 * math.pi / cnt
|
||||
for i0 in range(cnt):
|
||||
i1 = (i0 + 1) % cnt
|
||||
rel0,bmv0 = rels[i0],bmvs[i0]
|
||||
rel1,bmv1 = rels[i1],bmvs[i1]
|
||||
if rel0.length < 0.00001 or rel1.length < 0.00001: continue
|
||||
vec = bmv1.co - bmv0.co
|
||||
vec_len = vec.length
|
||||
fvec0 = rel0.cross(vec).cross(rel0).normalize()
|
||||
fvec1 = rel1.cross(rel1.cross(vec)).normalize()
|
||||
angle = rel0.angle(rel1)
|
||||
f_mag = (0.05 * (avg_angle - angle) * strength) / cnt #/ vec_len
|
||||
add_force(bmv0, fvec0 * -f_mag)
|
||||
add_force(bmv1, fvec1 * -f_mag)
|
||||
|
||||
# perform smoothing
|
||||
for step in range(opt_steps):
|
||||
if options['relax algorithm'] == '3D':
|
||||
relax_3d()
|
||||
elif options['relax algorithm'] == '2D':
|
||||
relax_2d()
|
||||
|
||||
if len(displace) <= 1: continue
|
||||
|
||||
# compute max displacement length
|
||||
displace_max = max(displace[bmv].length * (opt_mult * vert_strength[bmv]) for bmv in displace)
|
||||
if displace_max > radius * 0.125:
|
||||
# limit the displace_max
|
||||
mult = radius * 0.125 / displace_max
|
||||
else:
|
||||
mult = 1.0
|
||||
|
||||
# update
|
||||
for bmv in displace:
|
||||
co = bmv.co + displace[bmv] * (opt_mult * vert_strength[bmv]) * mult
|
||||
|
||||
if opt_mask_symmetry == 'maintain' and bmv.is_on_symmetry_plane():
|
||||
snap_to_symmetry = self.rfcontext.symmetry_planes_for_point(bmv.co)
|
||||
co = self.rfcontext.snap_to_symmetry(co, snap_to_symmetry)
|
||||
|
||||
if opt_mask_boundary == 'slide' and bmv.is_on_boundary():
|
||||
p, d = None, None
|
||||
for (v0, v1) in self._boundary:
|
||||
p_ = closest_point_segment(co, v0, v1)
|
||||
d_ = (p_ - co).length
|
||||
if p is None or d_ < d: p, d = p_, d_
|
||||
if p is not None:
|
||||
co = p
|
||||
|
||||
bmv.co = co
|
||||
self.rfcontext.snap_vert(bmv)
|
||||
self.rfcontext.update_verts_faces(displace)
|
||||
# print(f'relaxed {len(verts)} ({len(chk_verts)}) in {time.time() - st} with {strength}')
|
||||
|
||||
self.rfcontext.dirty()
|
||||
tag_redraw_all('Relax new frame')
|
||||
@@ -0,0 +1,271 @@
|
||||
<details id='relax-options'>
|
||||
<summary>Relax</summary>
|
||||
<div class="contents">
|
||||
<div class='collection' id='relax-masking'>
|
||||
<h1>Masking Options</h1>
|
||||
<div class='collection'>
|
||||
<h1>Boundary</h1>
|
||||
<div class='contents'>
|
||||
<label class='third-size'>
|
||||
<input type="radio" title='Relax vertices not along boundary' value='exclude' checked="BoundString('''options['relax mask boundary']''')" name='relax-boundary'>
|
||||
Exclude
|
||||
</label>
|
||||
<label class='third-size'>
|
||||
<input type="radio" title='Relax vertices along boundary, but move them by sliding along boundary' value='slide' checked="BoundString('''options['relax mask boundary']''')" name='relax-boundary'>
|
||||
Slide
|
||||
</label>
|
||||
<label class="third-size">
|
||||
<input type="radio" title="Relax all vertices within brush, regardless of being along boundary" value='include' checked="BoundString('''options['relax mask boundary']''')" name='relax-boundary'>
|
||||
Include
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class='collection'>
|
||||
<h1>Symmetry</h1>
|
||||
<div class='contents'>
|
||||
<label class='third-size'>
|
||||
<input type="radio" title='Relax vertices not along symmetry plane' value='exclude' checked="BoundString('''options['relax mask symmetry']''')" name='relax-symmetry'>
|
||||
Exclude
|
||||
</label>
|
||||
<label class='third-size'>
|
||||
<input type="radio" title='Relax vertices along symmetry plane, but move them by sliding along symmetry plane' value='maintain' checked="BoundString('''options['relax mask symmetry']''')" name='relax-symmetry'>
|
||||
Slide
|
||||
</label>
|
||||
<label class='third-size'>
|
||||
<input type="radio" title='Relax all vertices within brush, regardless of being along symmetry plane' value='include' checked="BoundString('''options['relax mask symmetry']''')" name='relax-symmetry'>
|
||||
Include
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class='collection'>
|
||||
<h1>Occluded</h1>
|
||||
<div class='contents'>
|
||||
<label class='half-size'>
|
||||
<input type="radio" title="Relax only vertices not occluded by other geometry" value='exclude' checked="BoundString('''options['relax mask occluded']''')" name='relax-occluded'>
|
||||
Exclude
|
||||
</label>
|
||||
<label class='half-size'>
|
||||
<input type="radio" title="Relax all vertices within brush, regardless of visibility" value='include' checked="BoundString('''options['relax mask occluded']''')" name='relax-occluded'>
|
||||
Include
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Selected</h1>
|
||||
<div class="contents">
|
||||
<label class="third-size">
|
||||
<input type="radio" title='Relax only unselected vertices' value='exclude' checked="BoundString('''options['relax mask selected']''')" name='relax-selected'>
|
||||
Exclude
|
||||
</label>
|
||||
<label class="third-size">
|
||||
<input type="radio" title='Relax only selected vertices' value='only' checked="BoundString('''options['relax mask selected']''')" name='relax-selected'>
|
||||
Only
|
||||
</label>
|
||||
<label class="third-size">
|
||||
<input type="radio" title='Relax all vertices within brush, regardless of selection' value='all' checked="BoundString('''options['relax mask selected']''')" name='relax-selected'>
|
||||
All
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details>
|
||||
<summary>Algorithm Options</summary>
|
||||
<div class="contents">
|
||||
<div class='collection'>
|
||||
<h1>Iterations</h1>
|
||||
<div class='contents'>
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-alg-steps">Steps</label>
|
||||
<input type="number" id="relax-alg-steps" title="Number of times to iterate" value="BoundInt('''options['relax steps']''', min_value=1, max_value=10)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-alg-strength">Strength</label>
|
||||
<input type="number" id="relax-alg-strength" title="Strength multiplier for each iteration" value="BoundFloat('''options['relax force multiplier']''', min_value=0.1, max_value=10.0)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='collection'>
|
||||
<h1>Edge</h1>
|
||||
<div class='contents'>
|
||||
<label>
|
||||
<input type="checkbox" title="Squash / stretch each edge toward the average edge length" checked="BoundBool('''options['relax edge length']''')">
|
||||
Average edge length
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" title="Try to straighten edges" checked="BoundBool('''options['relax straight edges']''')">
|
||||
Straighten edges
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class='collection'>
|
||||
<h1>Face</h1>
|
||||
<div class='contents'>
|
||||
<label>
|
||||
<input type="checkbox" title="Move face vertices so their distance to face center is equalized" checked="BoundBool('''options['relax face radius']''')">
|
||||
Face radius
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" title="Squash / stretch face edges so lengths are equal in length (WARNING: can cause faces to flip)" checked="BoundBool('''options['relax face sides']''')">
|
||||
Average face edge length
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" title="Move face vertices so they are equally spread around face center" checked="BoundBool('''options['relax face angles']''')">
|
||||
Face angles
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class='collection'>
|
||||
<h1>Experimental</h1>
|
||||
<div class='contents'>
|
||||
<label>
|
||||
<input type="checkbox" title="Try to move vertices so faces are not flipped" checked="BoundBool('''options['relax correct flipped faces']''')">
|
||||
Correct flipped faces
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Presets</h1>
|
||||
<div class="contents">
|
||||
<button title="Reset Algorithm options to default values" on_mouseclick="self.reset_algorithm_options()">Reset</button>
|
||||
<button title="Disable all Algorithm options" on_mouseclick="self.disable_all_options()">Disable All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Brush Options</summary>
|
||||
<div class="contents">
|
||||
<div class="collection" id='relax-current-brush'>
|
||||
<h1>Current</h1>
|
||||
<div class="contents">
|
||||
<div class='labeled-input-text'>
|
||||
<label for='relax-current-radius'>Size</label>
|
||||
<input type="number" title="Adjust brush size" id='relax-current-radius' value="self.rfwidgets['brushstroke'].get_radius_boundvar()">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for='relax-current-strength'>Strength</label>
|
||||
<input type="number" title="Adjust brush strength" id='relax-current-strength' value="self.rfwidgets['brushstroke'].get_strength_boundvar()">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for='relax-current-falloff'>Falloff</label>
|
||||
<input type="number" title="Adjust brush falloff" id='relax-current-falloff' value="self.rfwidgets['brushstroke'].get_falloff_boundvar()">
|
||||
</div>
|
||||
<button title="Reset brush options to defaults" on_mouseclick="self.reset_current_brush()">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<details id='relax-preset-1'>
|
||||
<summary id='relax-preset-1-summary'>Preset: Preset 1</summary>
|
||||
<div class="contents">
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-preset-1-name">Name</label>
|
||||
<input type="text" title="Change name of preset" id='relax-preset-1-name' value="BoundString('''options['relax preset 1 name']''')" on_change="self.update_preset_name(1)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-preset-1-radius">Size</label>
|
||||
<input type="number" title="Adjust brush size" id='relax-preset-1-radius' value="BoundInt('''options['relax preset 1 radius']''', min_value=1)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-preset-1-strength">Strength</label>
|
||||
<input type="number" title="Adjust brush strength" id='relax-preset-1-strength' value="BoundFloat('''options['relax preset 1 strength']''', min_value=0.01, max_value=1.0)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-preset-1-falloff">Falloff</label>
|
||||
<input type="number" title="Adjust brush falloff" id='relax-preset-1-falloff' value="BoundFloat('''options['relax preset 1 falloff']''', min_value=0.0, max_value=100.0)">
|
||||
</div>
|
||||
<div class='collection'>
|
||||
<h1>Copy Brush Settings</h1>
|
||||
<div class='contents'>
|
||||
<button title="Copy current brush settings to this preset" on_mouseclick="self.copy_current_to_preset(1)">Current to Preset</button>
|
||||
<button title="Copy this preset to current brush settings" on_mouseclick="self.copy_preset_to_current(1)">Preset to Current</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details id='relax-preset-2'>
|
||||
<summary id='relax-preset-2-summary'>Preset: Preset 2</summary>
|
||||
<div class="contents">
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-preset-2-name">Name</label>
|
||||
<input type="text" title="Change name of preset" id='relax-preset-2-name' value="BoundString('''options['relax preset 2 name']''')" on_change="self.update_preset_name(2)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-preset-2-radius">Size</label>
|
||||
<input type="number" title="Adjust brush size" id='relax-preset-2-radius' value="BoundInt('''options['relax preset 2 radius']''', min_value=1)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-preset-2-strength">Strength</label>
|
||||
<input type="number" title="Adjust brush strength" id='relax-preset-2-strength' value="BoundFloat('''options['relax preset 2 strength']''', min_value=0.01, max_value=1.0)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-preset-2-falloff">Falloff</label>
|
||||
<input type="number" title="Adjust brush falloff" id='relax-preset-2-falloff' value="BoundFloat('''options['relax preset 2 falloff']''', min_value=0.0, max_value=100.0)">
|
||||
</div>
|
||||
<div class='collection'>
|
||||
<h1>Copy Brush Settings</h1>
|
||||
<div class='contents'>
|
||||
<button title="Copy current brush settings to this preset" on_mouseclick="self.copy_current_to_preset(2)">Current to Preset</button>
|
||||
<button title="Copy this preset to current brush settings" on_mouseclick="self.copy_preset_to_current(2)">Preset to Current</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details id='relax-preset-3'>
|
||||
<summary id='relax-preset-3-summary'>Preset: Preset 3</summary>
|
||||
<div class="contents">
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-preset-3-name">Name</label>
|
||||
<input type="text" title="Change name of preset" id='relax-preset-3-name' value="BoundString('''options['relax preset 3 name']''')" on_change="self.update_preset_name(3)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-preset-3-radius">Size</label>
|
||||
<input type="number" title="Adjust brush size" id='relax-preset-3-radius' value="BoundInt('''options['relax preset 3 radius']''', min_value=1)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-preset-3-strength">Strength</label>
|
||||
<input type="number" title="Adjust brush strength" id='relax-preset-3-strength' value="BoundFloat('''options['relax preset 3 strength']''', min_value=0.01, max_value=1.0)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-preset-3-falloff">Falloff</label>
|
||||
<input type="number" title="Adjust brush falloff" id='relax-preset-3-falloff' value="BoundFloat('''options['relax preset 3 falloff']''', min_value=0.0, max_value=100.0)">
|
||||
</div>
|
||||
<div class='collection'>
|
||||
<h1>Copy Brush Settings</h1>
|
||||
<div class='contents'>
|
||||
<button title="Copy current brush settings to this preset" on_mouseclick="self.copy_current_to_preset(3)">Current to Preset</button>
|
||||
<button title="Copy this preset to current brush settings" on_mouseclick="self.copy_preset_to_current(3)">Preset to Current</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details id='relax-preset-4'>
|
||||
<summary id='relax-preset-4-summary'>Preset: Preset 4</summary>
|
||||
<div class="contents">
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-preset-4-name">Name</label>
|
||||
<input type="text" title="Change name of preset" id='relax-preset-4-name' value="BoundString('''options['relax preset 4 name']''')" on_change="self.update_preset_name(4)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-preset-4-radius">Size</label>
|
||||
<input type="number" title="Adjust brush size" id='relax-preset-4-radius' value="BoundInt('''options['relax preset 4 radius']''', min_value=1)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-preset-4-strength">Strength</label>
|
||||
<input type="number" title="Adjust brush strength" id='relax-preset-4-strength' value="BoundFloat('''options['relax preset 4 strength']''', min_value=0.01, max_value=1.0)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for="relax-preset-4-falloff">Falloff</label>
|
||||
<input type="number" title="Adjust brush falloff" id='relax-preset-4-falloff' value="BoundFloat('''options['relax preset 4 falloff']''', min_value=0.0, max_value=100.0)">
|
||||
</div>
|
||||
<div class='collection'>
|
||||
<h1>Copy Brush Settings</h1>
|
||||
<div class='contents'>
|
||||
<button title="Copy current brush settings to this preset" on_mouseclick="self.copy_current_to_preset(4)">Current to Preset</button>
|
||||
<button title="Copy this preset to current brush settings" on_mouseclick="self.copy_preset_to_current(4)">Preset to Current</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
@@ -0,0 +1,238 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import math
|
||||
import time
|
||||
from ..rftool import RFTool
|
||||
from ..rfwidget import RFWidget
|
||||
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
|
||||
from ..rfwidgets.rfwidget_selectbox import RFWidget_SelectBox_Factory
|
||||
from ..rfwidgets.rfwidget_hidden import RFWidget_Hidden_Factory
|
||||
|
||||
from ...addon_common.common.maths import (
|
||||
Vec, Vec2D,
|
||||
Point, Point2D,
|
||||
Direction,
|
||||
Color,
|
||||
closest_point_segment,
|
||||
)
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.boundvar import BoundBool, BoundInt, BoundFloat, BoundString
|
||||
from ...addon_common.common.maths import segment2D_intersection, Point2D, triangle2D_overlap
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.utils import iter_pairs, delay_exec, Dict
|
||||
from ...config.options import options, themes
|
||||
|
||||
|
||||
class Select(RFTool):
|
||||
name = 'Select'
|
||||
description = 'Select geometry'
|
||||
icon = 'select-icon.png'
|
||||
help = 'select.md'
|
||||
shortcut = 'select tool'
|
||||
quick_shortcut = 'select quick'
|
||||
statusbar = '{{select box}} Select\t{{select box del}}: Remove selection\t{{select box add}}: Add selection'
|
||||
ui_config = 'select_options.html'
|
||||
|
||||
RFWidget_Default = RFWidget_Default_Factory.create()
|
||||
RFWidget_SelectBox = RFWidget_SelectBox_Factory.create('Select: Box')
|
||||
RFWidget_Hidden = RFWidget_Hidden_Factory.create()
|
||||
|
||||
@RFTool.on_init
|
||||
def init(self):
|
||||
self.rfwidgets = {
|
||||
'default': self.RFWidget_Default(self),
|
||||
'selectbox': self.RFWidget_SelectBox(self),
|
||||
# circle select????
|
||||
'hidden': self.RFWidget_Hidden(self),
|
||||
}
|
||||
self.rfwidget = None
|
||||
|
||||
@RFTool.on_quickselect_start
|
||||
def quickselect_start(self):
|
||||
self.rfwidgets['selectbox'].quickselect_start()
|
||||
|
||||
@FSM.on_state('main')
|
||||
def main(self):
|
||||
self.set_widget('selectbox')
|
||||
|
||||
if self.actions.pressed({'select single', 'select single add'}, unpress=False):
|
||||
sel_only = self.actions.pressed('select single')
|
||||
self.actions.unpress()
|
||||
bmv,_ = self.rfcontext.accel_nearest2D_vert(max_dist=options['select dist'])
|
||||
bme,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['select dist'])
|
||||
bmf,_ = self.rfcontext.accel_nearest2D_face(max_dist=options['select dist'])
|
||||
sel = bmv or bme or bmf
|
||||
if not sel_only and not sel: return
|
||||
|
||||
self.rfcontext.undo_push('select')
|
||||
if sel_only: self.rfcontext.deselect_all()
|
||||
if not sel: return
|
||||
if sel.select: self.rfcontext.deselect(sel, subparts=False)
|
||||
else: self.rfcontext.select(sel, supparts=False, only=sel_only)
|
||||
return
|
||||
|
||||
if self.actions.pressed('grab'):
|
||||
self.rfcontext.undo_push('move grabbed')
|
||||
return 'move'
|
||||
|
||||
|
||||
def select_linked(self):
|
||||
self.rfcontext.undo_push('select linked')
|
||||
self.rfcontext.select_linked()
|
||||
|
||||
def deselect_all(self):
|
||||
self.rfcontext.undo_push('deselect all')
|
||||
self.rfcontext.deselect_all()
|
||||
|
||||
def select_invert(self):
|
||||
self.rfcontext.undo_push('invert selection')
|
||||
self.rfcontext.select_invert()
|
||||
|
||||
@RFWidget.on_action('Select: Box')
|
||||
def selectbox(self):
|
||||
box = self.rfwidgets['selectbox']
|
||||
p0, p1 = box.box2D
|
||||
if not p0 or not p1: return
|
||||
|
||||
(x0, y0), (x1, y1) = p0, p1
|
||||
left, right = min(x0, x1), max(x0, x1)
|
||||
bottom, top = min(y0, y1), max(y0, y1)
|
||||
c0, c1, c2, c3 = Point2D((left, top)), Point2D((left, bottom)), Point2D((right, bottom)), Point2D((right, top))
|
||||
tri0, tri1 = (c0, c1, c2), (c0, c2, c3)
|
||||
get_point2D = self.rfcontext.get_point2D
|
||||
|
||||
def vert_inside(vert):
|
||||
p = get_point2D(vert.co)
|
||||
return left <= p.x <= right and bottom <= p.y <= top
|
||||
|
||||
def edge_inside(edge):
|
||||
v0, v1 = edge.verts
|
||||
if vert_inside(v0) or vert_inside(v1): return True
|
||||
p0, p1 = get_point2D(v0.co), get_point2D(v1.co)
|
||||
return any((
|
||||
segment2D_intersection(c0, c1, p0, p1),
|
||||
segment2D_intersection(c1, c2, p0, p1),
|
||||
segment2D_intersection(c1, c3, p0, p1),
|
||||
segment2D_intersection(c3, c0, p0, p1),
|
||||
))
|
||||
|
||||
def face_inside(face):
|
||||
points = [get_point2D(v.co) for v in face.verts]
|
||||
p0 = points[0]
|
||||
return any((
|
||||
triangle2D_overlap((p0, p1, p2), tri0) or triangle2D_overlap((p0, p1, p2), tri1)
|
||||
for p1, p2 in zip(points[1:-1], points[2:])
|
||||
))
|
||||
|
||||
match options['select geometry']:
|
||||
case 'Verts':
|
||||
verts = {
|
||||
vert
|
||||
for vert in self.rfcontext.get_vis_verts()
|
||||
if vert_inside(vert)
|
||||
}
|
||||
case 'Edges':
|
||||
verts = {
|
||||
vert
|
||||
for edge in self.rfcontext.get_vis_edges()
|
||||
if edge_inside(edge)
|
||||
for vert in edge.verts
|
||||
}
|
||||
case 'Faces':
|
||||
verts = {
|
||||
vert
|
||||
for face in self.rfcontext.get_vis_faces()
|
||||
if face_inside(face)
|
||||
for vert in face.verts
|
||||
}
|
||||
|
||||
self.rfcontext.undo_push('select box')
|
||||
if box.mods['ctrl']: self.rfcontext.select(self.rfcontext.get_selected_verts() - verts, only=True) # del verts from selection
|
||||
elif box.mods['shift']: self.rfcontext.select(verts, only=False) # add vert to selection
|
||||
else: self.rfcontext.select(verts, only=True) # replace selection
|
||||
|
||||
|
||||
@FSM.on_state('move', 'enter')
|
||||
def move_enter(self):
|
||||
self.move_data = Dict()
|
||||
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
self.move_data.bmverts_xys = [
|
||||
(bmv, xy)
|
||||
for bmv in self.rfcontext.get_selected_verts()
|
||||
if bmv and bmv.is_valid and (xy := self.rfcontext.Point_to_Point2D(bmv.co))
|
||||
]
|
||||
self.move_data.bmverts = [ bmv for (bmv, _) in self.move_data.bmverts_xys ]
|
||||
|
||||
self.move_data.mousedown = self.actions.mouse
|
||||
self.move_data.last_delta = None
|
||||
|
||||
if options['select automerge']:
|
||||
self.move_data.vis_accel = self.rfcontext.get_custom_vis_accel(
|
||||
selection_only=False,
|
||||
include_edges=False,
|
||||
include_faces=False,
|
||||
symmetry=False,
|
||||
)
|
||||
|
||||
self.rfcontext.split_target_visualization_selected()
|
||||
self.rfcontext.fast_update_timer.start()
|
||||
self.rfcontext.set_accel_defer(True)
|
||||
|
||||
if options['hide cursor on tweak']: self.set_widget('hidden')
|
||||
|
||||
@FSM.on_state('move')
|
||||
def modal_move(self):
|
||||
if self.actions.pressed(['confirm', 'confirm drag']):
|
||||
if options['select automerge']:
|
||||
self.rfcontext.merge_verts_by_dist(self.move_data.bmverts, options['select merge dist'])
|
||||
return 'main'
|
||||
if self.actions.pressed('cancel'):
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
|
||||
@RFTool.on_mouse_move
|
||||
@RFTool.once_per_frame
|
||||
@FSM.onlyinstate('move')
|
||||
def modal_move_update(self):
|
||||
delta = Vec2D(self.actions.mouse - self.move_data.mousedown)
|
||||
if delta == self.move_data.last_delta: return
|
||||
self.move_data.last_delta = delta
|
||||
set2D_vert = self.rfcontext.set2D_vert
|
||||
for bmv,xy in self.move_data.bmverts_xys:
|
||||
if not xy: continue
|
||||
xy_updated = xy + delta
|
||||
if options['select automerge']:
|
||||
# snap xy_updated to any visible verts close enough to current xy_updated (in image plane)
|
||||
bmv1, _ = self.rfcontext.accel_nearest2D_vert(point=xy_updated, vis_accel=self.move_data.vis_accel, max_dist=options['select merge dist'])
|
||||
xy1 = self.rfcontext.Point_to_Point2D(bmv1.co) if bmv1 else None
|
||||
if xy1: xy_updated = xy1
|
||||
set2D_vert(bmv, xy_updated)
|
||||
self.rfcontext.update_verts_faces(self.move_data.bmverts)
|
||||
self.rfcontext.dirty()
|
||||
|
||||
@FSM.on_state('move', 'exit')
|
||||
def move_exit(self):
|
||||
self.rfcontext.set_accel_defer(False)
|
||||
self.rfcontext.fast_update_timer.stop()
|
||||
self.rfcontext.clear_split_target_visualization()
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<details id='select-options'>
|
||||
<summary>Select</summary>
|
||||
<div class="contents">
|
||||
<button title="Select everything connected to the current selection" on_mouseclick="self.select_linked()">Select Linked</button>
|
||||
<button title="Invert the current selection" on_mouseclick="self.select_invert()">Invert Selection</button>
|
||||
<button title="Deselect all geometry" on_mouseclick="self.deselect_all()">Deselect All</button>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['selection occlusion test']''')" title="If enabled, target geometry that is occluded by source(s) are not selectable. Disable if a vertex is not selectable." on_change="self.get_accel_visible()">
|
||||
Block Occluded
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['selection backface test']''')" title="If enabled, geometry that is facing away is not selectable. Disable if a vertex is not selectable." on_change="self.get_accel_visible()">
|
||||
Block Backface
|
||||
</label>
|
||||
<div class="collection">
|
||||
<h1>Selection Geometry</h1>
|
||||
<div class="contents">
|
||||
<label class='third-size'>
|
||||
<input type="radio" id='select-geo-verts' name='select-geo' value='Verts' checked="BoundString('''options['select geometry']''')" title='If checked, selection will focus on vertices.'>
|
||||
Verts
|
||||
</label>
|
||||
<label class='third-size'>
|
||||
<input type="radio" id="select-geo-edges" name="select-geo" value="Edges" checked="BoundString('''options['select geometry']''')" title='If checked, selection will focus on edges.'>
|
||||
Edges
|
||||
</label>
|
||||
<label class='third-size'>
|
||||
<input type="radio" id="select-geo-faces" name="select-geo" value="Faces" checked="BoundString('''options['select geometry']''')" title='If checked, selection will focus on faces.'>
|
||||
Faces
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Automerge</h1>
|
||||
<div class="contents">
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['select automerge']''')" title="If enabled, grabbed vertices automatically merged with nearby vertices">
|
||||
Enable Automerge
|
||||
</label>
|
||||
<div class="labeled-input-text">
|
||||
<label for="select-merge-distance">Merge distance</label>
|
||||
<input id="select-merge-distance" type="number" value="BoundInt( '''options['select merge dist'] ''')" title="Pixel distance for merging and snapping">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@@ -0,0 +1,436 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import math
|
||||
import time
|
||||
import bpy
|
||||
from math import isnan
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from mathutils import Vector, Matrix
|
||||
from mathutils.geometry import intersect_point_tri_2d
|
||||
|
||||
from ..rftool import RFTool
|
||||
from ..rfwidget import RFWidget
|
||||
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
|
||||
from ..rfwidgets.rfwidget_brushstroke import RFWidget_BrushStroke_Factory
|
||||
from ..rfwidgets.rfwidget_hidden import RFWidget_Hidden_Factory
|
||||
|
||||
from ...addon_common.common import gpustate
|
||||
from ...addon_common.common.debug import dprint
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.maths import (
|
||||
Point, Vec, Direction,
|
||||
Point2D, Vec2D,
|
||||
clamp, mid,
|
||||
)
|
||||
from ...addon_common.common.bezier import CubicBezierSpline, CubicBezier
|
||||
from ...addon_common.common.utils import iter_pairs, iter_running_sum, min_index, max_index, has_duplicates
|
||||
from ...addon_common.common.boundvar import BoundBool, BoundInt, BoundFloat
|
||||
from ...addon_common.common.drawing import DrawCallbacks
|
||||
from ...addon_common.common.timerhandler import StopwatchHandler
|
||||
from ...addon_common.terminal.term_printer import sprint
|
||||
from ...config.options import options, themes
|
||||
|
||||
from .strokes_insert import Strokes_Insert
|
||||
from .strokes_utils import (
|
||||
process_stroke_filter, process_stroke_source,
|
||||
find_edge_cycles,
|
||||
find_edge_strips, get_strip_verts,
|
||||
restroke, walk_to_corner,
|
||||
)
|
||||
|
||||
|
||||
class Strokes(RFTool, Strokes_Insert):
|
||||
name = 'Strokes'
|
||||
description = 'Insert edge strips and extrude edges into a patch'
|
||||
icon = 'strokes-icon.png'
|
||||
help = 'strokes.md'
|
||||
shortcut = 'strokes tool'
|
||||
statusbar = '{{insert}} Insert edge strip and bridge\t{{increase count}} Increase segments\t{{decrease count}} Decrease segments'
|
||||
ui_config = 'strokes_options.html'
|
||||
|
||||
RFWidget_Default = RFWidget_Default_Factory.create()
|
||||
RFWidget_Move = RFWidget_Default_Factory.create(cursor='HAND')
|
||||
RFWidget_Hidden = RFWidget_Hidden_Factory.create()
|
||||
RFWidget_BrushStroke = RFWidget_BrushStroke_Factory.create(
|
||||
'Strokes stroke',
|
||||
BoundInt('''options['strokes radius']''', min_value=1),
|
||||
outer_border_color=themes['strokes'],
|
||||
)
|
||||
|
||||
def _fsm_in_main(self):
|
||||
# needed so main actions using Ctrl (ex: undo, redo, save) can still work
|
||||
return self._fsm.state in {'main', 'previs insert'}
|
||||
|
||||
|
||||
@property
|
||||
def cross_count(self):
|
||||
return self.strip_crosses or 0
|
||||
@cross_count.setter
|
||||
def cross_count(self, v):
|
||||
if self.strip_crosses == v: return
|
||||
if self.replay is None: return
|
||||
if self.strip_crosses is None: return
|
||||
self.strip_crosses = v
|
||||
if self.strip_crosses is not None: self.replay()
|
||||
|
||||
@property
|
||||
def loop_count(self):
|
||||
return self.strip_loops or 0
|
||||
@loop_count.setter
|
||||
def loop_count(self, v):
|
||||
if self.strip_loops == v: return
|
||||
if self.replay is None: return
|
||||
if self.strip_loops is None: return
|
||||
self.strip_loops = v
|
||||
if self.strip_loops is not None: self.replay()
|
||||
|
||||
@RFTool.on_init
|
||||
def init(self):
|
||||
self.rfwidgets = {
|
||||
'default': self.RFWidget_Default(self),
|
||||
'brush': self.RFWidget_BrushStroke(self),
|
||||
'hover': self.RFWidget_Move(self),
|
||||
'hidden': self.RFWidget_Hidden(self),
|
||||
}
|
||||
self.rfwidget = None
|
||||
self.strip_crosses = None
|
||||
self.strip_loops = None
|
||||
self._var_fixed_span_count = BoundInt('''options['strokes span count']''', min_value=1, max_value=128)
|
||||
self._var_cross_count = BoundInt('''self.cross_count''', min_value=1, max_value=500)
|
||||
self._var_loop_count = BoundInt('''self.loop_count''', min_value=1, max_value=500)
|
||||
|
||||
|
||||
def update_span_mode(self):
|
||||
mode = options['strokes span insert mode']
|
||||
self.ui_summary.innerText = f'Strokes: {mode}'
|
||||
self.ui_insert.dirty(cause='insert mode change', children=True)
|
||||
|
||||
@RFTool.on_ui_setup
|
||||
def ui(self):
|
||||
ui_options = self.document.body.getElementById('strokes-options')
|
||||
self.ui_summary = ui_options.getElementById('strokes-summary')
|
||||
self.ui_insert = ui_options.getElementById('strokes-insert-modes')
|
||||
self.ui_radius = ui_options.getElementById('strokes-radius')
|
||||
def dirty_radius():
|
||||
self.ui_radius.dirty(cause='radius changed')
|
||||
self.rfwidgets['brush'].get_radius_boundvar().on_change(dirty_radius)
|
||||
self.update_span_mode()
|
||||
|
||||
@RFTool.on_reset
|
||||
def reset(self):
|
||||
self.replay = None
|
||||
self.strip_crosses = None
|
||||
self.strip_loops = None
|
||||
self.strip_edges = False
|
||||
self.just_created = False
|
||||
self.defer_recomputing = False
|
||||
self.hovering_sel_edge = None
|
||||
self.connection_pre_last_mouse = None
|
||||
self.connection_pre = None
|
||||
self.connection_post = None
|
||||
self.update_hover_edge()
|
||||
self.update_ui()
|
||||
|
||||
def update_ui(self):
|
||||
if self.replay is None:
|
||||
self._var_cross_count.disabled = True
|
||||
self._var_loop_count.disabled = True
|
||||
else:
|
||||
self._var_cross_count.disabled = self.strip_crosses is None or self.strip_edges
|
||||
self._var_loop_count.disabled = self.strip_loops is None
|
||||
|
||||
@RFTool.on_target_change
|
||||
def update_target(self):
|
||||
if self.defer_recomputing: return
|
||||
if not self.just_created: self.reset()
|
||||
else: self.just_created = False
|
||||
|
||||
@RFTool.on_target_change
|
||||
@RFTool.on_view_change
|
||||
def update(self):
|
||||
if self.defer_recomputing: return
|
||||
|
||||
self.update_ui()
|
||||
|
||||
self.edge_collections = []
|
||||
edges = self.get_edges_for_extrude()
|
||||
while edges:
|
||||
current = set()
|
||||
working = set([edges.pop()])
|
||||
while working:
|
||||
e = working.pop()
|
||||
if e in current: continue
|
||||
current.add(e)
|
||||
edges.discard(e)
|
||||
v0,v1 = e.verts
|
||||
working |= {e for e in (v0.link_edges + v1.link_edges) if e in edges}
|
||||
verts = {v for e in current for v in e.verts}
|
||||
self.edge_collections.append({
|
||||
'verts': verts,
|
||||
'edges': current,
|
||||
'center': Point.average(v.co for v in verts),
|
||||
})
|
||||
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
def draw_postpixel_counts(self):
|
||||
gpustate.blend('ALPHA')
|
||||
point_to_point2d = self.rfcontext.Point_to_Point2D
|
||||
text_draw2D = self.rfcontext.drawing.text_draw2D
|
||||
self.rfcontext.drawing.set_font_size(12)
|
||||
|
||||
for collection in self.edge_collections:
|
||||
lv = len(collection['verts'])
|
||||
le = len(collection['edges'])
|
||||
c = collection['center']
|
||||
xy = point_to_point2d(c)
|
||||
if not xy: continue
|
||||
xy.y += 10
|
||||
t = f'V:{lv}, E:{le}'
|
||||
if self.strip_crosses: t += f'\nSpan: {self.strip_crosses}'
|
||||
if self.strip_loops: t += f'\nLoop: {self.strip_loops}'
|
||||
text_draw2D(t, xy, color=(1,1,0,1), dropshadow=(0,0,0,0.5))
|
||||
|
||||
|
||||
def filter_edge_selection(self, bme):
|
||||
return bme.select or len(bme.link_faces) < 2
|
||||
|
||||
@RFTool.on_events('reset', 'target change', 'view change', 'mouse move')
|
||||
@RFTool.once_per_frame
|
||||
@FSM.onlyinstate('main')
|
||||
def update_hover_edge(self):
|
||||
self.hovering_sel_edge,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['action dist'], selected_only=True)
|
||||
|
||||
|
||||
@FSM.on_state('main', 'enter')
|
||||
def modal_main_enter(self):
|
||||
self.connection_post = None
|
||||
self.update_hover_edge()
|
||||
|
||||
@FSM.on_state('main')
|
||||
def modal_main(self):
|
||||
if self.actions.using_onlymods('insert'):
|
||||
return 'previs insert'
|
||||
|
||||
if self.hovering_sel_edge:
|
||||
self.set_widget('hover')
|
||||
else:
|
||||
self.set_widget('default')
|
||||
|
||||
|
||||
if self.handle_inactive_passthrough(): return
|
||||
|
||||
if self.rfcontext.actions.pressed('pie menu alt0'):
|
||||
def callback(option):
|
||||
if not option: return
|
||||
options['strokes span insert mode'] = option
|
||||
self.update_span_mode()
|
||||
self.rfcontext.show_pie_menu([
|
||||
'Brush Size',
|
||||
'Fixed',
|
||||
], callback, highlighted=options['strokes span insert mode'])
|
||||
return
|
||||
|
||||
if self.hovering_sel_edge and self.actions.pressed('action'):
|
||||
self.move_done_pressed = None
|
||||
self.move_done_released = 'action'
|
||||
self.move_cancelled = 'cancel'
|
||||
return 'move'
|
||||
|
||||
if self.actions.pressed({'select path add'}):
|
||||
return self.rfcontext.select_path(
|
||||
{'edge'},
|
||||
fn_filter_bmelem=self.filter_edge_selection,
|
||||
kwargs_select={'supparts': False},
|
||||
)
|
||||
|
||||
if self.actions.pressed({'select paint', 'select paint add'}, unpress=False):
|
||||
sel_only = self.actions.pressed('select paint')
|
||||
self.actions.unpress()
|
||||
return self.rfcontext.setup_smart_selection_painting(
|
||||
{'edge'},
|
||||
use_select_tool=True,
|
||||
selecting=not sel_only,
|
||||
deselect_all=sel_only,
|
||||
kwargs_select={'supparts': False},
|
||||
kwargs_deselect={'subparts': False},
|
||||
)
|
||||
|
||||
if self.actions.pressed({'select single', 'select single add'}, unpress=False):
|
||||
sel_only = self.actions.pressed('select single')
|
||||
self.actions.unpress()
|
||||
bme,_ = self.rfcontext.accel_nearest2D_edge(max_dist=options['select dist'])
|
||||
if not sel_only and not bme: return
|
||||
self.rfcontext.undo_push('select')
|
||||
if sel_only: self.rfcontext.deselect_all()
|
||||
if not bme: return
|
||||
if bme.select: self.rfcontext.deselect(bme, subparts=False)
|
||||
else: self.rfcontext.select(bme, supparts=False, only=sel_only)
|
||||
return
|
||||
|
||||
|
||||
if self.rfcontext.actions.pressed({'select smart', 'select smart add'}, unpress=False):
|
||||
sel_only = self.rfcontext.actions.pressed('select smart')
|
||||
self.rfcontext.actions.unpress()
|
||||
|
||||
self.rfcontext.undo_push('select smart')
|
||||
selectable_edges = [e for e in self.rfcontext.visible_edges() if len(e.link_faces) < 2]
|
||||
edge,_ = self.rfcontext.nearest2D_edge(edges=selectable_edges, max_dist=10)
|
||||
if not edge: return
|
||||
#self.rfcontext.select_inner_edge_loop(edge, supparts=False, only=sel_only)
|
||||
self.rfcontext.select_edge_loop(edge, supparts=False, only=sel_only)
|
||||
|
||||
if self.rfcontext.actions.pressed('grab'):
|
||||
self.move_done_pressed = 'confirm'
|
||||
self.move_done_released = None
|
||||
self.move_cancelled = 'cancel'
|
||||
return 'move'
|
||||
|
||||
if self.rfcontext.actions.pressed('increase count') and self.replay:
|
||||
# print('increase count')
|
||||
if self.strip_crosses is not None and not self.strip_edges:
|
||||
self.strip_crosses += 1
|
||||
self.replay()
|
||||
elif self.strip_loops is not None:
|
||||
self.strip_loops += 1
|
||||
self.replay()
|
||||
|
||||
if self.rfcontext.actions.pressed('decrease count') and self.replay:
|
||||
# print('decrease count')
|
||||
if self.strip_crosses is not None and self.strip_crosses > 1 and not self.strip_edges:
|
||||
self.strip_crosses -= 1
|
||||
self.replay()
|
||||
elif self.strip_loops is not None and self.strip_loops > 1:
|
||||
self.strip_loops -= 1
|
||||
self.replay()
|
||||
|
||||
def mergeSnapped(self):
|
||||
""" Merging colocated visible verts """
|
||||
|
||||
if not options['strokes automerge']: return
|
||||
|
||||
# TODO: remove colocated faces
|
||||
if self.mousedown is None: return
|
||||
delta = Vec2D(self.actions.mouse - self.mousedown)
|
||||
set2D_vert = self.rfcontext.set2D_vert
|
||||
update_verts = []
|
||||
merge_dist = self.rfcontext.drawing.scale(options['strokes merge dist'])
|
||||
for bmv,xy in self.bmverts:
|
||||
if not xy: continue
|
||||
xy_updated = xy + delta
|
||||
for bmv1,xy1 in self.vis_bmverts:
|
||||
if not xy1: continue
|
||||
if bmv1 == bmv: continue
|
||||
if not bmv1.is_valid: continue
|
||||
d = (xy_updated - xy1).length
|
||||
if (xy_updated - xy1).length > merge_dist:
|
||||
continue
|
||||
bmv1.merge_robust(bmv)
|
||||
self.rfcontext.select(bmv1)
|
||||
update_verts += [bmv1]
|
||||
break
|
||||
if update_verts:
|
||||
self.rfcontext.update_verts_faces(update_verts)
|
||||
#self.set_next_state()
|
||||
|
||||
@FSM.on_state('move', 'enter')
|
||||
def move_enter(self):
|
||||
self.rfcontext.undo_push('move grabbed')
|
||||
|
||||
self.move_opts = {
|
||||
'vis_accel': self.rfcontext.get_custom_vis_accel(
|
||||
selection_only=False,
|
||||
include_edges=False,
|
||||
include_faces=False,
|
||||
symmetry=False,
|
||||
),
|
||||
}
|
||||
|
||||
sel_verts = self.rfcontext.get_selected_verts()
|
||||
vis_accel = self.rfcontext.get_accel_visible()
|
||||
vis_verts = self.rfcontext.accel_vis_verts
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
|
||||
bmverts = [(bmv, Point_to_Point2D(bmv.co)) for bmv in sel_verts]
|
||||
self.bmverts = [(bmv, co) for (bmv, co) in bmverts if co]
|
||||
self.vis_bmverts = [(bmv, Point_to_Point2D(bmv.co)) for bmv in vis_verts if bmv.is_valid and bmv not in sel_verts]
|
||||
self.mousedown = self.rfcontext.actions.mouse
|
||||
self.defer_recomputing = True
|
||||
self.rfcontext.split_target_visualization_selected()
|
||||
self.rfcontext.set_accel_defer(True)
|
||||
self._timer = self.actions.start_timer(120)
|
||||
|
||||
if options['hide cursor on tweak']: self.set_widget('hidden')
|
||||
|
||||
@FSM.on_state('move')
|
||||
@RFTool.dirty_when_done
|
||||
def move(self):
|
||||
released = self.rfcontext.actions.released
|
||||
if self.actions.pressed(self.move_done_pressed):
|
||||
self.defer_recomputing = False
|
||||
self.mergeSnapped()
|
||||
return 'main'
|
||||
if self.actions.released(self.move_done_released):
|
||||
self.defer_recomputing = False
|
||||
self.mergeSnapped()
|
||||
return 'main'
|
||||
if self.actions.pressed('cancel'):
|
||||
self.defer_recomputing = False
|
||||
self.actions.unuse(self.move_done_released, ignoremods=True, ignoremulti=True)
|
||||
self.rfcontext.undo_cancel()
|
||||
return 'main'
|
||||
|
||||
# only update verts on timer events and when mouse has moved
|
||||
#if not self.rfcontext.actions.timer: return
|
||||
#if self.actions.mouse_prev == self.actions.mouse: return
|
||||
if not self.actions.mousemove_stop: return
|
||||
|
||||
delta = Vec2D(self.rfcontext.actions.mouse - self.mousedown)
|
||||
set2D_vert = self.rfcontext.set2D_vert
|
||||
for bmv,xy in self.bmverts:
|
||||
xy_updated = xy + delta
|
||||
# check if xy_updated is "close" to any visible verts (in image plane)
|
||||
# if so, snap xy_updated to vert position (in image plane)
|
||||
if options['polypen automerge']:
|
||||
bmv1,d = self.rfcontext.accel_nearest2D_vert(point=xy_updated, vis_accel=self.move_opts['vis_accel'], max_dist=options['strokes merge dist'])
|
||||
if bmv1 is None:
|
||||
set2D_vert(bmv, xy_updated)
|
||||
continue
|
||||
xy1 = self.rfcontext.Point_to_Point2D(bmv1.co)
|
||||
if not xy1:
|
||||
set2D_vert(bmv, xy_updated)
|
||||
continue
|
||||
set2D_vert(bmv, xy1)
|
||||
else:
|
||||
set2D_vert(bmv, xy_updated)
|
||||
self.rfcontext.update_verts_faces(v for v,_ in self.bmverts)
|
||||
|
||||
@FSM.on_state('move', 'exit')
|
||||
def move_exit(self):
|
||||
self._timer.done()
|
||||
self.rfcontext.set_accel_defer(False)
|
||||
self.rfcontext.clear_split_target_visualization()
|
||||
|
||||
@@ -0,0 +1,784 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import math
|
||||
import time
|
||||
import bpy
|
||||
from math import isnan
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from mathutils import Vector, Matrix
|
||||
from mathutils.geometry import intersect_point_tri_2d
|
||||
|
||||
from ..rftool import RFTool
|
||||
from ..rfwidget import RFWidget
|
||||
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
|
||||
from ..rfwidgets.rfwidget_brushstroke import RFWidget_BrushStroke_Factory
|
||||
from ..rfwidgets.rfwidget_hidden import RFWidget_Hidden_Factory
|
||||
|
||||
from ...addon_common.common import gpustate
|
||||
from ...addon_common.common.debug import dprint
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.maths import (
|
||||
Point, Vec, Direction,
|
||||
Point2D, Vec2D,
|
||||
clamp, mid,
|
||||
Color,
|
||||
)
|
||||
from ...addon_common.common.bezier import CubicBezierSpline, CubicBezier
|
||||
from ...addon_common.common.utils import iter_pairs, iter_running_sum, min_index, max_index, has_duplicates
|
||||
from ...addon_common.common.boundvar import BoundBool, BoundInt, BoundFloat
|
||||
from ...addon_common.common.drawing import DrawCallbacks
|
||||
from ...addon_common.common.timerhandler import StopwatchHandler
|
||||
from ...addon_common.terminal.term_printer import sprint
|
||||
from ...config.options import options, themes
|
||||
|
||||
from .strokes_utils import (
|
||||
process_stroke_filter, process_stroke_source,
|
||||
find_edge_cycles,
|
||||
find_edge_strips, get_strip_verts,
|
||||
restroke, walk_to_corner,
|
||||
)
|
||||
|
||||
|
||||
class Strokes_Insert():
|
||||
@FSM.on_state('previs insert', 'enter')
|
||||
def modal_previs_enter(self):
|
||||
self.set_widget('brush')
|
||||
self.rfcontext.fast_update_timer.enable(True)
|
||||
self.rfwidget.inner_color = Color((1, 1, 1, 0.5)) if options['strokes snap stroke'] else Color((1, 1, 1, 0.0625))
|
||||
self.rfwidget.inner_radius = options['strokes snap dist']
|
||||
|
||||
self.connection_pre = None
|
||||
self.connection_post = None
|
||||
|
||||
def _nearest_connection(self):
|
||||
if not options['strokes snap stroke']: return None
|
||||
vert, _ = self.rfcontext.accel_nearest2D_vert(max_dist=options['strokes snap dist'])
|
||||
if not vert: return None
|
||||
return (vert, (self.rfcontext.Point_to_Point2D(vert.co), self.actions.mouse))
|
||||
|
||||
@FSM.on_state('previs insert')
|
||||
def modal_previs(self):
|
||||
if self.handle_inactive_passthrough(): return
|
||||
|
||||
if self.actions.pressed('insert'):
|
||||
return 'insert'
|
||||
|
||||
if not self.actions.using_onlymods('insert'):
|
||||
return 'main'
|
||||
|
||||
@FSM.on_state('previs insert', 'exit')
|
||||
def modal_previs_exit(self):
|
||||
self.rfcontext.fast_update_timer.enable(False)
|
||||
|
||||
@RFTool.on_events('mouse move')
|
||||
@RFTool.once_per_frame
|
||||
@FSM.onlyinstate('previs insert')
|
||||
def update_connection_prepost(self):
|
||||
# only called when in insert previs but not stroking...
|
||||
self.connection_pre = self._nearest_connection()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def defer_recomputing_while(self):
|
||||
try:
|
||||
self.defer_recomputing = True
|
||||
yield
|
||||
finally:
|
||||
self.defer_recomputing = False
|
||||
self.update()
|
||||
|
||||
|
||||
@RFWidget.on_actioning('Strokes stroke')
|
||||
def stroking(self):
|
||||
self.connection_post = self._nearest_connection()
|
||||
|
||||
@RFWidget.on_action('Strokes stroke')
|
||||
def stroke(self):
|
||||
# called when artist finishes a stroke
|
||||
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
raycast_sources_Point2D = self.rfcontext.raycast_sources_Point2D
|
||||
accel_nearest2D_vert = self.rfcontext.accel_nearest2D_vert
|
||||
|
||||
# filter stroke down where each pt is at least 1px away to eliminate local wiggling
|
||||
radius = self.rfwidgets['brush'].radius
|
||||
stroke = self.rfwidgets['brush'].stroke2D
|
||||
stroke = process_stroke_filter(stroke)
|
||||
stroke = process_stroke_source(
|
||||
stroke,
|
||||
raycast_sources_Point2D,
|
||||
Point_to_Point2D=Point_to_Point2D,
|
||||
clamp_point_to_symmetry=self.rfcontext.clamp_point_to_symmetry,
|
||||
)
|
||||
stroke3D = [raycast_sources_Point2D(s)[0] for s in stroke]
|
||||
stroke3D = [s for s in stroke3D if s]
|
||||
|
||||
# bail if there aren't enough stroke data points to work with
|
||||
if len(stroke3D) < 2: return
|
||||
|
||||
sel_verts = self.rfcontext.get_selected_verts()
|
||||
sel_edges = self.rfcontext.get_selected_edges()
|
||||
s0, s1 = Point_to_Point2D(stroke3D[0]), Point_to_Point2D(stroke3D[-1])
|
||||
bmv0 = self.connection_pre[0] if self.connection_pre else None
|
||||
bmv1 = self.connection_post[0] if self.connection_post else None
|
||||
if not options['strokes snap stroke']:
|
||||
if bmv0 and not bmv0.select: bmv0 = None
|
||||
if bmv1 and not bmv1.select: bmv1 = None
|
||||
bmv0_sel = bmv0 and bmv0 in sel_verts
|
||||
bmv1_sel = bmv1 and bmv1 in sel_verts
|
||||
|
||||
if bmv0:
|
||||
stroke3D = [bmv0.co] + stroke3D
|
||||
if bmv1:
|
||||
stroke3D = stroke3D + [bmv1.co]
|
||||
|
||||
self.strip_stroke3D = stroke3D
|
||||
self.strip_crosses = None
|
||||
self.strip_loops = None
|
||||
self.strip_edges = False
|
||||
self.replay = None
|
||||
|
||||
boundary_edges = self.get_edges_for_extrude()
|
||||
|
||||
# are we extruding or creating a new edge strip/loop?
|
||||
extrude = bool(boundary_edges)
|
||||
|
||||
# is the stroke in a circle? note: circle must have a large enough radius
|
||||
cyclic = (stroke[0] - stroke[-1]).length < radius
|
||||
cyclic &= any((s - stroke[0]).length > 2.0 * radius for s in stroke)
|
||||
|
||||
# need to determine shape of extrusion
|
||||
# key: |- stroke (‾_/\)
|
||||
# C corner in stroke (roughly 90° angle, but not easy to detect. what if the stroke loops over itself?)
|
||||
# ǁ= selected boundary or wire edges
|
||||
# O vertex under stroke
|
||||
# X corner vertex (edges change direction)
|
||||
# notes:
|
||||
# - vertex under stroke must be at beginning or ending of stroke
|
||||
# - vertices are "under stroke" if they are selected or if "Snap Stroke to Unselected" is enabled
|
||||
|
||||
# Strip Cycle L-shape C-shape T-shape U-shape I-shape Equals O-shape D-shape
|
||||
# | /‾‾‾\ | O------ ===O=== ǁ ǁ ===O=== ====== X=====O O-----C
|
||||
# | | | | ǁ | ǁ ǁ | ǁ | ǁ |
|
||||
# | \___/ O====== X====== | O-----O ===O=== ------ X=====O O-----C
|
||||
|
||||
# so far only Strip, Cycle, L, U, Strip are implemented. C, T, I, O, D are not yet implemented
|
||||
|
||||
# L vs C: there is a corner vertex in the edges (could we extend the L shape??)
|
||||
# D has corners in the stroke, which will be tricky to determine... use acceleration?
|
||||
|
||||
face_islands = list(self.get_edge_connected_faces(boundary_edges))
|
||||
# print(f'stroke: {len(boundary_edges)} {len(face_islands)}')
|
||||
# print(face_islands)
|
||||
|
||||
if extrude:
|
||||
if cyclic:
|
||||
# print(f'Extrude Cycle')
|
||||
self.replay = self.extrude_cycle
|
||||
else:
|
||||
if any([bmv0_sel, bmv1_sel]):
|
||||
if not all([bmv0_sel, bmv1_sel]):
|
||||
bmv = bmv0 if bmv0_sel else bmv1
|
||||
if len(set(bmv.link_edges) & sel_edges) == 1:
|
||||
# print(f'Extrude L or C')
|
||||
self.replay = self.extrude_l
|
||||
else:
|
||||
# print(f'Extrude T')
|
||||
self.replay = self.extrude_t
|
||||
else:
|
||||
# print(f'Extrude U or O or I')
|
||||
# XXX: I-shaped extrusions?
|
||||
self.replay = self.extrude_u
|
||||
else:
|
||||
# print(f'Extrude Equals')
|
||||
self.replay = self.extrude_equals
|
||||
else:
|
||||
if cyclic:
|
||||
# print(f'Create Cycle')
|
||||
self.replay = self.create_cycle
|
||||
else:
|
||||
# print(f'Create Strip')
|
||||
self.replay = self.create_strip
|
||||
|
||||
self.connection_pre = None
|
||||
self.connection_post = None
|
||||
|
||||
if self.replay: self.replay()
|
||||
|
||||
def get_edges_for_extrude(self, only_closest=None):
|
||||
edges = { e for e in self.rfcontext.get_selected_edges() if e.is_boundary or e.is_wire }
|
||||
if not only_closest:
|
||||
return edges
|
||||
# TODO: find vert-connected-edge-island that has the edge closest to stroke
|
||||
return edges
|
||||
|
||||
def get_vert_connected_edges(self, edges):
|
||||
edges = set(edges)
|
||||
while edges:
|
||||
island = set()
|
||||
working = { next(iter(edges)) }
|
||||
while working:
|
||||
edge = working.pop()
|
||||
if edge not in edges: continue
|
||||
edges.remove(edge)
|
||||
island.add(edge)
|
||||
working |= { e for v in edge.verts for e in v.link_edges }
|
||||
yield island
|
||||
|
||||
def get_edge_connected_faces(self, edges):
|
||||
edges = set(edges)
|
||||
while edges:
|
||||
island = set()
|
||||
working = { next(iter(edges)) }
|
||||
while working:
|
||||
edge = working.pop()
|
||||
if edge not in edges: continue
|
||||
edges.remove(edge)
|
||||
faces = set(edge.link_faces)
|
||||
island |= faces
|
||||
working |= { e2 for f in faces for e in f.edges for f2 in e.link_faces for e2 in f2.edges }
|
||||
yield island
|
||||
|
||||
@RFTool.dirty_when_done
|
||||
def create_cycle(self):
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
stroke = [Point_to_Point2D(s) for s in self.strip_stroke3D]
|
||||
stroke += stroke[:1]
|
||||
if not all(stroke): return # part of stroke cannot project
|
||||
|
||||
if self.strip_crosses is not None:
|
||||
self.rfcontext.undo_repush('create cycle')
|
||||
else:
|
||||
self.rfcontext.undo_push('create cycle')
|
||||
|
||||
if self.strip_crosses is None:
|
||||
if options['strokes span insert mode'] == 'Brush Size':
|
||||
stroke_len = sum((s1 - s0).length for (s0, s1) in iter_pairs(stroke, wrap=False))
|
||||
self.strip_crosses = max(1, math.ceil(stroke_len / (2 * self.rfwidgets['brush'].radius)))
|
||||
else:
|
||||
self.strip_crosses = options['strokes span count']
|
||||
crosses = self.strip_crosses
|
||||
percentages = [i / crosses for i in range(crosses)]
|
||||
nstroke = restroke(stroke, percentages)
|
||||
|
||||
if len(nstroke) <= 2:
|
||||
# too few vertices for a cycle
|
||||
self.rfcontext.alert_user(
|
||||
'Could not find create cycle from stroke. Please try again.'
|
||||
)
|
||||
return
|
||||
|
||||
with self.defer_recomputing_while():
|
||||
verts = [self.rfcontext.new2D_vert_point(s) for s in nstroke]
|
||||
edges = [self.rfcontext.new_edge([v0, v1]) for (v0, v1) in iter_pairs(verts, wrap=True)]
|
||||
self.rfcontext.select(edges)
|
||||
self.just_created = True
|
||||
|
||||
@RFTool.dirty_when_done
|
||||
def create_strip(self):
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
stroke = [Point_to_Point2D(s) for s in self.strip_stroke3D]
|
||||
if not all(stroke): return # part of stroke cannot project
|
||||
|
||||
if self.strip_crosses is not None:
|
||||
self.rfcontext.undo_repush('create strip')
|
||||
else:
|
||||
self.rfcontext.undo_push('create strip')
|
||||
|
||||
self.rfcontext.get_accel_visible(force=True)
|
||||
|
||||
if self.strip_crosses is None:
|
||||
if options['strokes span insert mode'] == 'Brush Size':
|
||||
stroke_len = sum((s1 - s0).length for (s0, s1) in iter_pairs(stroke, wrap=False))
|
||||
self.strip_crosses = max(1, math.ceil(stroke_len / (2 * self.rfwidgets['brush'].radius)))
|
||||
else:
|
||||
self.strip_crosses = options['strokes span count']
|
||||
crosses = self.strip_crosses
|
||||
percentages = [i / crosses for i in range(crosses+1)]
|
||||
nstroke = restroke(stroke, percentages)
|
||||
|
||||
if len(nstroke) < 2: return # too few stroke points, from a short stroke?
|
||||
|
||||
snap0,_ = self.rfcontext.accel_nearest2D_vert(point=nstroke[0], max_dist=options['strokes merge dist']) # self.rfwidgets['brush'].radius)
|
||||
snap1,_ = self.rfcontext.accel_nearest2D_vert(point=nstroke[-1], max_dist=options['strokes merge dist']) # self.rfwidgets['brush'].radius)
|
||||
if not options['strokes snap stroke'] and snap0 and not snap0.select: snap0 = None
|
||||
if not options['strokes snap stroke'] and snap1 and not snap1.select: snap1 = None
|
||||
|
||||
with self.defer_recomputing_while():
|
||||
verts = [self.rfcontext.new2D_vert_point(s) for s in nstroke]
|
||||
verts = [vert for vert in verts if vert]
|
||||
edges = [self.rfcontext.new_edge([v0, v1]) for (v0, v1) in iter_pairs(verts, wrap=False)]
|
||||
|
||||
if snap0:
|
||||
co = snap0.co
|
||||
verts[0].merge(snap0)
|
||||
verts[0].co = co
|
||||
self.rfcontext.clean_duplicate_bmedges(verts[0])
|
||||
if snap1:
|
||||
co = snap1.co
|
||||
verts[-1].merge(snap1)
|
||||
verts[-1].co = co
|
||||
self.rfcontext.clean_duplicate_bmedges(verts[-1])
|
||||
|
||||
self.rfcontext.select(edges)
|
||||
self.just_created = True
|
||||
|
||||
@RFTool.dirty_when_done
|
||||
def extrude_cycle(self):
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
stroke = [Point_to_Point2D(s) for s in self.strip_stroke3D]
|
||||
if not all(stroke): return # part of stroke cannot project
|
||||
|
||||
if self.strip_loops is not None:
|
||||
self.rfcontext.undo_repush('extrude cycle')
|
||||
else:
|
||||
self.rfcontext.undo_push('extrude cycle')
|
||||
pass
|
||||
|
||||
sctr = Point2D.average(stroke)
|
||||
stroke_centered = [(s - sctr) for s in stroke]
|
||||
|
||||
# make sure stroke is counter-clockwise
|
||||
winding = sum((s0.x * s1.y - s1.x * s0.y) for (s0, s1) in iter_pairs(stroke_centered, wrap=False))
|
||||
if winding < 0:
|
||||
stroke.reverse()
|
||||
stroke_centered.reverse()
|
||||
|
||||
# get selected edges that we can extrude
|
||||
edges = self.get_edges_for_extrude()
|
||||
# find cycle in selection
|
||||
best = None
|
||||
best_score = None
|
||||
for edge_cycle in find_edge_cycles(edges):
|
||||
verts = get_strip_verts(edge_cycle)
|
||||
vctr = Point2D.average([Point_to_Point2D(v.co) for v in verts])
|
||||
score = (sctr - vctr).length
|
||||
if not best or score < best_score:
|
||||
best = edge_cycle
|
||||
best_score = score
|
||||
if not best:
|
||||
self.rfcontext.alert_user(
|
||||
'Could not find suitable edge cycle. Make sure your selection is accurate.'
|
||||
)
|
||||
return
|
||||
|
||||
edge_cycle = best
|
||||
vert_cycle = get_strip_verts(edge_cycle)[:-1] # first and last verts are same---loop!
|
||||
vctr = Point2D.average([Point_to_Point2D(v.co) for v in vert_cycle])
|
||||
verts_centered = [(Point_to_Point2D(v.co) - vctr) for v in vert_cycle]
|
||||
|
||||
# make sure edge cycle is counter-clockwise
|
||||
winding = sum((v0.x * v1.y - v1.x * v0.y) for (v0, v1) in iter_pairs(verts_centered, wrap=False))
|
||||
if winding < 0:
|
||||
edge_cycle.reverse()
|
||||
vert_cycle.reverse()
|
||||
verts_centered.reverse()
|
||||
|
||||
# rotate cycle until first vert has smallest y
|
||||
idx = min_index(vert_cycle, lambda v:Point_to_Point2D(v.co).y)
|
||||
edge_cycle = edge_cycle[idx:] + edge_cycle[:idx]
|
||||
vert_cycle = vert_cycle[idx:] + vert_cycle[:idx]
|
||||
verts_centered = verts_centered[idx:] + verts_centered[:idx]
|
||||
|
||||
# rotate stroke until first point matches best with vert_cycle
|
||||
v = verts_centered[0] / verts_centered[0].length
|
||||
idx = max_index(stroke_centered, lambda s:(s.x * v.x + s.y * v.y) / s.length)
|
||||
stroke = stroke[idx:] + stroke[:idx]
|
||||
stroke += stroke[:1]
|
||||
|
||||
crosses = len(edge_cycle)
|
||||
percentages = [i / crosses for i in range(crosses)]
|
||||
nstroke = restroke(stroke, percentages)
|
||||
|
||||
if self.strip_loops is None:
|
||||
self.strip_loops = max(1, math.ceil(1)) # TODO: calculate!
|
||||
loops = self.strip_loops
|
||||
|
||||
with self.defer_recomputing_while():
|
||||
patch = []
|
||||
for i in range(crosses):
|
||||
v = Point_to_Point2D(vert_cycle[i].co)
|
||||
s = nstroke[i]
|
||||
cur_line = [vert_cycle[i]]
|
||||
for j in range(1, loops+1):
|
||||
pj = j / loops
|
||||
cur_line.append(self.rfcontext.new2D_vert_point(Point2D.weighted_average([
|
||||
(pj, s),
|
||||
(1 - pj, v)
|
||||
])))
|
||||
patch.append(cur_line)
|
||||
for i0 in range(crosses):
|
||||
i1 = (i0 + 1) % crosses
|
||||
for j0 in range(loops):
|
||||
j1 = j0 + 1
|
||||
self.rfcontext.new_face([patch[i0][j0], patch[i0][j1], patch[i1][j1], patch[i1][j0]])
|
||||
end_verts = [l[-1] for l in patch]
|
||||
edges = [v0.shared_edge(v1) for (v0, v1) in iter_pairs(end_verts, wrap=True)]
|
||||
|
||||
self.rfcontext.select(edges)
|
||||
self.just_created = True
|
||||
|
||||
@RFTool.dirty_when_done
|
||||
def extrude_u(self):
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
new2D_vert_point = self.rfcontext.new2D_vert_point
|
||||
new_face = self.rfcontext.new_face
|
||||
|
||||
stroke = [Point_to_Point2D(s) for s in self.strip_stroke3D]
|
||||
if not all(stroke): return # part of stroke cannot project
|
||||
|
||||
if self.strip_crosses is not None:
|
||||
self.rfcontext.undo_repush('extrude U')
|
||||
else:
|
||||
self.rfcontext.undo_push('extrude U')
|
||||
|
||||
self.rfcontext.get_accel_visible(force=True)
|
||||
|
||||
# get selected edges that we can extrude
|
||||
edges = self.get_edges_for_extrude()
|
||||
sel_verts = {v for e in edges for v in e.verts}
|
||||
|
||||
s0, s1 = stroke[0], stroke[-1]
|
||||
bmv0,_ = self.rfcontext.accel_nearest2D_vert(point=s0, max_dist=options['strokes merge dist']) # self.rfwidgets['brush'].radius)
|
||||
bmv1,_ = self.rfcontext.accel_nearest2D_vert(point=s1, max_dist=options['strokes merge dist']) # self.rfwidgets['brush'].radius)
|
||||
bmv0 = bmv0 if bmv0 in sel_verts else None
|
||||
bmv1 = bmv1 if bmv1 in sel_verts else None
|
||||
assert bmv0 and bmv1
|
||||
|
||||
edges0,verts0 = [],[bmv0]
|
||||
while True:
|
||||
bmes = set(verts0[-1].link_edges) & edges
|
||||
if edges0: bmes.remove(edges0[-1])
|
||||
if len(bmes) != 1: break
|
||||
bme = bmes.pop()
|
||||
edges0.append(bme)
|
||||
verts0.append(bme.other_vert(verts0[-1]))
|
||||
points0 = [Point_to_Point2D(v.co) for v in verts0]
|
||||
diffs0 = [(p1 - points0[0]) for p1 in points0]
|
||||
|
||||
edges1,verts1 = [],[bmv1]
|
||||
while True:
|
||||
bmes = set(verts1[-1].link_edges) & edges
|
||||
if edges1: bmes.remove(edges1[-1])
|
||||
if len(bmes) != 1: break
|
||||
bme = bmes.pop()
|
||||
edges1.append(bme)
|
||||
verts1.append(bme.other_vert(verts1[-1]))
|
||||
points1 = [Point_to_Point2D(v.co) for v in verts1]
|
||||
diffs1 = [(p1 - points1[0]) for p1 in points1]
|
||||
|
||||
if len(diffs0) != len(diffs1):
|
||||
self.rfcontext.alert_user(
|
||||
'Selections must contain same number of edges'
|
||||
)
|
||||
return
|
||||
|
||||
if self.strip_crosses is None:
|
||||
if options['strokes span insert mode'] == 'Brush Size':
|
||||
stroke_len = sum((s1 - s0).length for (s0, s1) in iter_pairs(stroke, wrap=False))
|
||||
self.strip_crosses = max(1, math.ceil(stroke_len / (2 * self.rfwidgets['brush'].radius)))
|
||||
else:
|
||||
self.strip_crosses = options['strokes span count']
|
||||
crosses = self.strip_crosses
|
||||
percentages = [i / crosses for i in range(crosses+1)]
|
||||
nstroke = restroke(stroke, percentages)
|
||||
nsegments = len(diffs0)
|
||||
|
||||
with self.defer_recomputing_while():
|
||||
nedges = []
|
||||
nverts = None
|
||||
for istroke,s in enumerate(nstroke):
|
||||
pverts = nverts
|
||||
if istroke == 0:
|
||||
nverts = verts0
|
||||
elif istroke == crosses:
|
||||
nverts = verts1
|
||||
else:
|
||||
p = istroke / crosses
|
||||
offsets = [diffs0[i] * (1 - p) + diffs1[i] * p for i in range(nsegments)]
|
||||
nverts = [new2D_vert_point(s + offset) for offset in offsets]
|
||||
if pverts:
|
||||
for i in range(len(nverts)-1):
|
||||
lst = [pverts[i], pverts[i+1], nverts[i+1], nverts[i]]
|
||||
if all(lst) and not has_duplicates(lst):
|
||||
new_face(lst)
|
||||
bmv1 = nverts[0]
|
||||
nedges.append(bmv0.shared_edge(bmv1))
|
||||
bmv0 = bmv1
|
||||
|
||||
self.rfcontext.select(nedges)
|
||||
self.just_created = True
|
||||
|
||||
@RFTool.dirty_when_done
|
||||
def extrude_t(self):
|
||||
self.rfcontext.alert_user(
|
||||
'T-shaped extrusions are not handled, yet'
|
||||
)
|
||||
|
||||
@RFTool.dirty_when_done
|
||||
def extrude_l(self):
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
stroke = [Point_to_Point2D(s) for s in self.strip_stroke3D]
|
||||
if not all(stroke): return # part of stroke cannot project
|
||||
|
||||
if self.strip_crosses is not None:
|
||||
self.rfcontext.undo_repush('extrude L')
|
||||
else:
|
||||
self.rfcontext.undo_push('extrude L')
|
||||
|
||||
self.rfcontext.get_accel_visible(force=True)
|
||||
|
||||
new2D_vert_point = self.rfcontext.new2D_vert_point
|
||||
new_face = self.rfcontext.new_face
|
||||
|
||||
# get selected edges that we can extrude
|
||||
edges = self.get_edges_for_extrude()
|
||||
sel_verts = { v for e in edges for v in e.verts }
|
||||
|
||||
s0, s1 = stroke[0], stroke[-1]
|
||||
bmv0,_ = self.rfcontext.accel_nearest2D_vert(point=s0, max_dist=options['strokes merge dist']) # self.rfwidgets['brush'].radius)
|
||||
bmv1,_ = self.rfcontext.accel_nearest2D_vert(point=s1, max_dist=options['strokes merge dist']) # self.rfwidgets['brush'].radius)
|
||||
bmv0 = bmv0 if bmv0 in sel_verts else None
|
||||
bmv1 = bmv1 if bmv1 in sel_verts else None
|
||||
if bmv1 in sel_verts:
|
||||
# reverse stroke
|
||||
stroke.reverse()
|
||||
s0, s1 = s1, s0
|
||||
bmv0, bmv1 = bmv1, None
|
||||
if not bmv0:
|
||||
# possible fix for issue #870?
|
||||
# could not find a vert to extrude from?
|
||||
self.rfcontext.undo_cancel()
|
||||
return
|
||||
nedges,nverts = [],[bmv0]
|
||||
while True:
|
||||
bmes = set(nverts[-1].link_edges) & edges
|
||||
if nedges: bmes.remove(nedges[-1])
|
||||
if len(bmes) != 1: break
|
||||
bme = next(iter(bmes))
|
||||
nedges.append(bme)
|
||||
nverts.append(bme.other_vert(nverts[-1]))
|
||||
npoints = [Point_to_Point2D(v.co) for v in nverts]
|
||||
ndiffs = [(p1 - npoints[0]) for p1 in npoints]
|
||||
|
||||
if self.strip_crosses is None:
|
||||
if options['strokes span insert mode'] == 'Brush Size':
|
||||
stroke_len = sum((s1 - s0).length for (s0, s1) in iter_pairs(stroke, wrap=False))
|
||||
self.strip_crosses = max(1, math.ceil(stroke_len / (2 * self.rfwidgets['brush'].radius)))
|
||||
else:
|
||||
self.strip_crosses = options['strokes span count']
|
||||
crosses = self.strip_crosses
|
||||
percentages = [i / crosses for i in range(crosses+1)]
|
||||
nstroke = restroke(stroke, percentages)
|
||||
|
||||
with self.defer_recomputing_while():
|
||||
nedges = []
|
||||
for s in nstroke[1:]:
|
||||
pverts = nverts
|
||||
nverts = [new2D_vert_point(s+d) for d in ndiffs]
|
||||
for i in range(len(nverts)-1):
|
||||
lst = [pverts[i], pverts[i+1], nverts[i+1], nverts[i]]
|
||||
if all(lst) and not has_duplicates(lst):
|
||||
new_face(lst)
|
||||
bmv1 = nverts[0]
|
||||
if bmv0 and bmv1:
|
||||
nedges.append(bmv0.shared_edge(bmv1))
|
||||
bmv0 = bmv1
|
||||
|
||||
self.rfcontext.select(nedges)
|
||||
self.just_created = True
|
||||
|
||||
@RFTool.dirty_when_done
|
||||
def extrude_equals(self):
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
stroke = [Point_to_Point2D(s) for s in self.strip_stroke3D]
|
||||
if not all(stroke): return # part of stroke cannot project
|
||||
|
||||
if self.strip_crosses is not None:
|
||||
self.rfcontext.undo_repush('extrude strip')
|
||||
else:
|
||||
self.rfcontext.undo_push('extrude strip')
|
||||
|
||||
# get selected edges that we can extrude
|
||||
edges = self.get_edges_for_extrude()
|
||||
sel_verts = { v for e in edges for v in e.verts }
|
||||
|
||||
self.rfcontext.get_accel_visible(force=True)
|
||||
|
||||
s0, s1 = stroke[0], stroke[-1]
|
||||
sd = s1 - s0
|
||||
|
||||
# check if verts near stroke ends connect to any of the selected strips
|
||||
bmv0,_ = self.rfcontext.accel_nearest2D_vert(point=s0, max_dist=options['strokes merge dist']) # self.rfwidgets['brush'].radius)
|
||||
bmv1,_ = self.rfcontext.accel_nearest2D_vert(point=s1, max_dist=options['strokes merge dist']) # self.rfwidgets['brush'].radius)
|
||||
if not options['strokes snap stroke'] and bmv0 and not bmv0.select: bmv0 = None
|
||||
if not options['strokes snap stroke'] and bmv1 and not bmv1.select: bmv1 = None
|
||||
edges0 = walk_to_corner(bmv0, edges) if bmv0 else []
|
||||
edges1 = walk_to_corner(bmv1, edges) if bmv1 else []
|
||||
edges0 = [e for e in edges0 if e.is_valid] if edges0 else None
|
||||
edges1 = [e for e in edges1 if e.is_valid] if edges1 else None
|
||||
if edges0 and edges1 and len(edges0) != len(edges1):
|
||||
self.rfcontext.alert_user(
|
||||
'Edge strips near ends of stroke have different counts. Make sure your stroke is accurate.'
|
||||
)
|
||||
return
|
||||
if edges0:
|
||||
self.strip_crosses = len(edges0)
|
||||
self.strip_edges = True
|
||||
if edges1:
|
||||
self.strip_crosses = len(edges1)
|
||||
self.strip_edges = True
|
||||
# TODO: set best and ensure that best connects edges0 and edges1
|
||||
|
||||
# check all strips for best "scoring"
|
||||
best = None
|
||||
best_score = None
|
||||
for edge_strip in find_edge_strips(edges):
|
||||
verts = get_strip_verts(edge_strip)
|
||||
p0, p1 = Point_to_Point2D(verts[0].co), Point_to_Point2D(verts[-1].co)
|
||||
if not p0 or not p1: continue
|
||||
pd = p1 - p0
|
||||
dot = pd.x * sd.x + pd.y * sd.y
|
||||
if dot < 0:
|
||||
edge_strip.reverse()
|
||||
p0, p1, pd, dot = p1, p0, -pd, -dot
|
||||
score = ((s0 - p0).length + (s1 - p1).length) #* (1 - dot)
|
||||
if not best or score < best_score:
|
||||
best = edge_strip
|
||||
best_score = score
|
||||
if not best:
|
||||
self.rfcontext.alert_user(
|
||||
'Could not determine which edge strip to extrude from. Make sure your selection is accurate.'
|
||||
)
|
||||
return
|
||||
|
||||
if len(best) == 1:
|
||||
# special case where reversing the edge strip will NOT prevent twisted faces
|
||||
verts = best[0].verts
|
||||
p0, p1 = Point_to_Point2D(verts[0].co), Point_to_Point2D(verts[-1].co)
|
||||
if p0 and p1:
|
||||
pd = p1 - p0
|
||||
dot = pd.x * sd.x + pd.y * sd.y
|
||||
if dot < 0:
|
||||
# reverse stroke!
|
||||
stroke.reverse()
|
||||
s0, s1 = s1, s0
|
||||
sd = -sd
|
||||
|
||||
# tessellate stroke to match edge
|
||||
edges = best
|
||||
verts = get_strip_verts(edges)
|
||||
edge_lens = [
|
||||
(Point_to_Point2D(e.verts[0].co) - Point_to_Point2D(e.verts[1].co)).length
|
||||
for e in edges
|
||||
]
|
||||
strip_len = sum(edge_lens)
|
||||
avg_len = strip_len / len(edges)
|
||||
per_lens = [l / strip_len for l in edge_lens]
|
||||
percentages = [0] + [max(0, min(1, s)) for (w, s) in iter_running_sum(per_lens)]
|
||||
nstroke = restroke(stroke, percentages)
|
||||
assert len(nstroke) == len(verts), f'Tessellated stroke ({len(nstroke)}) does not match vert count ({len(verts)})'
|
||||
|
||||
# average distance between stroke and strip
|
||||
p0, p1 = Point_to_Point2D(verts[0].co), Point_to_Point2D(verts[-1].co)
|
||||
avg_dist = ((p0 - s0).length + (p1 - s1).length) / 2
|
||||
if isnan(avg_dist):
|
||||
self.rfcontext.alert_user(
|
||||
'Could not determine distance between stroke and selected strip. Please try again.'
|
||||
)
|
||||
return
|
||||
|
||||
# determine cross count
|
||||
if self.strip_crosses is None:
|
||||
if options['strokes span insert mode'] == 'Brush Size':
|
||||
self.strip_crosses = max(math.ceil(avg_dist / (2 * self.rfwidgets['brush'].radius)), 2)
|
||||
else:
|
||||
self.strip_crosses = options['strokes span count']
|
||||
crosses = self.strip_crosses + 1
|
||||
|
||||
with self.defer_recomputing_while():
|
||||
# extrude!
|
||||
patch = []
|
||||
prev, last = None, []
|
||||
for (v0, p1) in zip(verts, nstroke):
|
||||
p0 = Point_to_Point2D(v0.co)
|
||||
cur = [v0] + [
|
||||
self.rfcontext.new2D_vert_point(p0 + (p1-p0) * (c / (crosses-1)))
|
||||
for c in range(1, crosses)
|
||||
]
|
||||
patch += [cur]
|
||||
last.append(cur[-1])
|
||||
if prev:
|
||||
for i in range(crosses-1):
|
||||
nface = [prev[i+0], cur[i+0], cur[i+1], prev[i+1]]
|
||||
if all(nface):
|
||||
self.rfcontext.new_face(nface)
|
||||
else:
|
||||
for v0,v1 in iter_pairs(nface, True):
|
||||
if v0 and v1 and not v0.share_edge(v1):
|
||||
self.rfcontext.new_edge([v0, v1])
|
||||
prev = cur
|
||||
|
||||
edges0 = [e for e in edges0 if e.is_valid] if edges0 else None
|
||||
edges1 = [e for e in edges1 if e.is_valid] if edges1 else None
|
||||
|
||||
if edges0:
|
||||
side_verts = get_strip_verts(edges0)
|
||||
if side_verts[1] == verts[0]: side_verts.reverse()
|
||||
for a,b in zip(side_verts[1:], patch[0][1:]):
|
||||
co = a.co
|
||||
b.merge(a)
|
||||
b.co = co
|
||||
self.rfcontext.clean_duplicate_bmedges(b)
|
||||
if edges1:
|
||||
side_verts = get_strip_verts(edges1)
|
||||
if side_verts[1] == verts[-1]: side_verts.reverse()
|
||||
for a,b in zip(side_verts[1:], patch[-1][1:]):
|
||||
co = a.co
|
||||
b.merge(a)
|
||||
b.co = co
|
||||
self.rfcontext.clean_duplicate_bmedges(b)
|
||||
|
||||
nedges = [
|
||||
v0.shared_edge(v1)
|
||||
for (v0, v1) in iter_pairs(last, wrap=False)
|
||||
if v0 and v1
|
||||
]
|
||||
|
||||
self.rfcontext.select(nedges)
|
||||
self.just_created = True
|
||||
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate('previs insert')
|
||||
def draw_postpixel_strokeconnect(self):
|
||||
gpustate.blend('ALPHA')
|
||||
|
||||
if self.connection_pre:
|
||||
Globals.drawing.draw2D_linestrip(self.connection_pre[1], themes['stroke'], width=2, stipple=[4,4])
|
||||
if self.connection_post:
|
||||
Globals.drawing.draw2D_linestrip(self.connection_post[1], themes['stroke'], width=2, stipple=[4,4])
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<script type="python">
|
||||
def set_span_mode(self, value):
|
||||
elems = self.document.body.getElementsByName('strokes-span-mode')
|
||||
for e in elems:
|
||||
if e.value == value:
|
||||
e.checked = True
|
||||
self.document.body.getElementById('strokes-insert-modes').dirty()
|
||||
</script>
|
||||
|
||||
<details id="strokes-options">
|
||||
<summary id="strokes-summary">Strokes</summary>
|
||||
<div class="contents">
|
||||
<div class='collection' id="strokes-insert-modes">
|
||||
<h1>Span Insert Mode</h1>
|
||||
<div class='contents'>
|
||||
<label class="half-size">
|
||||
<input type="radio" id='strokes-span-mode-brush' value='Brush Size' checked="BoundString('''options['strokes span insert mode']''')" name='strokes-span-mode' on_input="self.update_span_mode()" title="Insert spans based on brush size">
|
||||
Brush Size
|
||||
</label>
|
||||
<label class="half-size">
|
||||
<input type="radio" id='strokes-span-mode-fixed' value='Fixed' checked="BoundString('''options['strokes span insert mode']''')" name='strokes-span-mode' on_input="self.update_span_mode()" title="Insert fixed number of spans">
|
||||
Fixed
|
||||
</label>
|
||||
<div class="labeled-input-text">
|
||||
<label for="strokes-radius">Brush size</label>
|
||||
<input type="number" id="strokes-radius" value="self.rfwidgets['brush'].get_radius_boundvar()" on_focus="set_span_mode(self, 'Brush Size')" title="Adjust brush size. When Span Insert Mode is set to Brush Size, the number of spans inserted is based on the size of the brush.">
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="strokes-fixed-spans">Fixed spans</label>
|
||||
<input type="number" id="strokes-fixed-spans" value="self._var_fixed_span_count" on_focus="set_span_mode(self, 'Fixed')" title="When Span Insert Mode is set to Fixed, the number of spans inserted is exactly this number.">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Snap Stroke</h1>
|
||||
<div class="contents">
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['strokes snap stroke']''')" title="Allows the start and end of the stroke to snap to nearby vertices">
|
||||
Enable Stroke Snapping
|
||||
</label>
|
||||
<div class="labeled-input-text">
|
||||
<label for="strokes-snap-distance">Snap distance</label>
|
||||
<input id="strokes-snap-distance" type="number" value="BoundInt( '''options['strokes snap dist'] ''')" title="Pixel distance for snapping stroke to nearby geometry (only when Stroke Snapping is enabled)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Automerge</h1>
|
||||
<div class="contents">
|
||||
<label>
|
||||
<input type="checkbox" checked="BoundBool('''options['strokes automerge']''')" title="If enabled, grabbed vertices automatically merged with nearby vertices">
|
||||
Enable Automerge
|
||||
</label>
|
||||
<div class="labeled-input-text">
|
||||
<label for="strokes-merge-distance">Merge distance</label>
|
||||
<input id="strokes-merge-distance" type="number" value="BoundInt( '''options['strokes merge dist'] ''')" title="Pixel distance for merging and snapping (only when Automerge is enabled)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>New Geometry Edits</h1>
|
||||
<div class="contents">
|
||||
<div class="labeled-input-text">
|
||||
<label for="strokes-new-spans">Spans</label>
|
||||
<input type="number" id="strokes-new-spans" value="self._var_cross_count" title="Number of spans between previously selected strip and newly created strip">
|
||||
</div>
|
||||
<div class="labeled-input-text">
|
||||
<label for="strokes-new-loops">Loops</label>
|
||||
<input type="number" id="strokes-new-loops" value="self._var_loop_count" title="Number of loops between previously selected loop and newly created loop">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@@ -0,0 +1,196 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import bpy
|
||||
import math
|
||||
from mathutils import Vector, Matrix
|
||||
from mathutils.geometry import intersect_line_line_2d
|
||||
from ...addon_common.common.debug import dprint
|
||||
from ...addon_common.common.maths import Point,Point2D,Vec2D,Vec, Normal, clamp
|
||||
from ...addon_common.common.bezier import CubicBezierSpline, CubicBezier
|
||||
from ...addon_common.common.utils import iter_pairs
|
||||
|
||||
|
||||
def process_stroke_filter(stroke, min_distance=1.0, max_distance=2.0):
|
||||
''' filter stroke to pts that are at least min_distance apart '''
|
||||
nstroke = stroke[:1]
|
||||
for p in stroke[1:]:
|
||||
v = p - nstroke[-1]
|
||||
l = v.length
|
||||
if l < min_distance: continue
|
||||
d = v / l
|
||||
while l > 0:
|
||||
q = nstroke[-1] + d * min(l, max_distance)
|
||||
nstroke.append(q)
|
||||
l -= max_distance
|
||||
return nstroke
|
||||
|
||||
def process_stroke_source(stroke, raycast, Point_to_Point2D=None, is_point_on_mirrored_side=None, mirror_point=None, clamp_point_to_symmetry=None):
|
||||
''' filter out pts that don't hit source on non-mirrored side '''
|
||||
pts = [(pt, raycast(pt)[0]) for pt in stroke]
|
||||
pts = [(pt, p3d) for (pt, p3d) in pts if p3d]
|
||||
if Point_to_Point2D and mirror_point:
|
||||
pts_ = [Point_to_Point2D(mirror_point(p3d)) for (_, p3d) in pts]
|
||||
pts = [(pt, raycast(pt)[0]) for pt in pts_]
|
||||
pts = [(pt, p3d) for (pt, p3d) in pts if p3d]
|
||||
if Point_to_Point2D and clamp_point_to_symmetry:
|
||||
pts_ = [Point_to_Point2D(clamp_point_to_symmetry(p3d)) for (_, p3d) in pts]
|
||||
pts = [(pt, raycast(pt)[0]) for pt in pts_]
|
||||
pts = [(pt, p3d) for (pt, p3d) in pts if p3d]
|
||||
if is_point_on_mirrored_side:
|
||||
pts = [(pt, p3d) for (pt, p3d) in pts if not is_point_on_mirrored_side(p3d)]
|
||||
return [pt for (pt, _) in pts]
|
||||
|
||||
def find_edge_cycles(edges):
|
||||
edges = set(edges)
|
||||
verts = {v: set() for e in edges for v in e.verts}
|
||||
for e in edges:
|
||||
for v in e.verts:
|
||||
verts[v].add(e)
|
||||
in_cycle = set()
|
||||
for vstart in verts:
|
||||
if vstart in in_cycle: continue
|
||||
for estart in vstart.link_edges:
|
||||
if estart not in edges: continue
|
||||
if estart in in_cycle: continue
|
||||
q = [(estart, vstart, None)]
|
||||
found = None
|
||||
trace = {}
|
||||
while q:
|
||||
ec, vc, ep = q.pop(0)
|
||||
if ec in trace: continue
|
||||
trace[ec] = (vc, ep)
|
||||
vn = ec.other_vert(vc)
|
||||
if vn == vstart:
|
||||
found = ec
|
||||
break
|
||||
q += [(en, vn, ec) for en in vn.link_edges if en in edges]
|
||||
if not found: continue
|
||||
l = [found]
|
||||
in_cycle.add(found)
|
||||
while True:
|
||||
vn, ep = trace[l[-1]]
|
||||
in_cycle.add(vn)
|
||||
in_cycle.add(ep)
|
||||
if vn == vstart: break
|
||||
l.append(ep)
|
||||
yield l
|
||||
|
||||
def find_edge_strips(edges):
|
||||
''' find edge strips '''
|
||||
edges = set(edges)
|
||||
verts = {v: set() for e in edges for v in e.verts}
|
||||
for e in edges:
|
||||
for v in e.verts:
|
||||
verts[v].add(e)
|
||||
ends = [v for v in verts if len(verts[v]) == 1]
|
||||
def get_edge_sequence(v0, v1):
|
||||
trace = {}
|
||||
q = [(None, v0)]
|
||||
while q:
|
||||
vf,vt = q.pop(0)
|
||||
if vt in trace: continue
|
||||
trace[vt] = vf
|
||||
if vt == v1: break
|
||||
for e in verts[vt]:
|
||||
q.append((vt, e.other_vert(vt)))
|
||||
if v1 not in trace: return []
|
||||
l = []
|
||||
while v1 is not None:
|
||||
l.append(v1)
|
||||
v1 = trace[v1]
|
||||
l.reverse()
|
||||
return [v0.shared_edge(v1) for (v0, v1) in iter_pairs(l, wrap=False)]
|
||||
for i0 in range(len(ends)):
|
||||
for i1 in range(i0+1,len(ends)):
|
||||
l = get_edge_sequence(ends[i0], ends[i1])
|
||||
if l: yield l
|
||||
|
||||
def get_strip_verts(edge_strip):
|
||||
l = len(edge_strip)
|
||||
if l == 0: return []
|
||||
if l == 1:
|
||||
e = edge_strip[0]
|
||||
return list(e.verts) if e.is_valid else []
|
||||
vs = []
|
||||
for e0, e1 in iter_pairs(edge_strip, wrap=False):
|
||||
vs.append(e0.shared_vert(e1))
|
||||
vs = [edge_strip[0].other_vert(vs[0])] + vs + [edge_strip[-1].other_vert(vs[-1])]
|
||||
return vs
|
||||
|
||||
|
||||
def restroke(stroke, percentages):
|
||||
lens = [(s0 - s1).length for (s0, s1) in iter_pairs(stroke, wrap=False)]
|
||||
total_len = sum(lens)
|
||||
stops = [max(0, min(1, p)) * total_len for p in percentages]
|
||||
dist = 0
|
||||
istroke = 0
|
||||
istop = 0
|
||||
nstroke = []
|
||||
while istroke + 1 < len(stroke) and istop < len(stops):
|
||||
if lens[istroke] <= 0:
|
||||
istroke += 1
|
||||
continue
|
||||
t = (stops[istop] - dist) / lens[istroke]
|
||||
if t < 0:
|
||||
istop += 1
|
||||
elif t > 1.000001:
|
||||
dist += lens[istroke]
|
||||
istroke += 1
|
||||
else:
|
||||
s0, s1 = stroke[istroke], stroke[istroke + 1]
|
||||
nstroke.append(s0 + (s1 - s0) * t)
|
||||
istop += 1
|
||||
return nstroke
|
||||
|
||||
def walk_to_corner(from_vert, to_edges):
|
||||
to_verts = {v for e in to_edges for v in e.verts}
|
||||
edges = [
|
||||
(e, from_vert, None)
|
||||
for e in from_vert.link_edges
|
||||
if not e.is_manifold and e.is_valid
|
||||
]
|
||||
touched = {}
|
||||
found = None
|
||||
while edges:
|
||||
ec, v0, ep = edges.pop(0)
|
||||
if ec in touched: continue
|
||||
touched[ec] = (v0, ep)
|
||||
v1 = ec.other_vert(v0)
|
||||
if v1 in to_verts:
|
||||
found = ec
|
||||
break
|
||||
nedges = [
|
||||
(en, v1, ec)
|
||||
for en in v1.link_edges
|
||||
if en != ec and not en.is_manifold and en.is_valid
|
||||
]
|
||||
edges += nedges
|
||||
if not found: return None
|
||||
# walk back
|
||||
walk = [found]
|
||||
while True:
|
||||
ec = walk[-1]
|
||||
v0, ep = touched[ec]
|
||||
if v0 == from_vert:
|
||||
break
|
||||
walk.append(ep)
|
||||
return walk
|
||||
@@ -0,0 +1,245 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
from ..rftool import RFTool
|
||||
from ..rfwidgets.rfwidget_default import RFWidget_Default_Factory
|
||||
from ..rfwidgets.rfwidget_brushfalloff import RFWidget_BrushFalloff_Factory
|
||||
|
||||
from ...addon_common.common.drawing import (
|
||||
CC_DRAW,
|
||||
CC_2D_POINTS,
|
||||
CC_2D_LINES, CC_2D_LINE_LOOP,
|
||||
CC_2D_TRIANGLES, CC_2D_TRIANGLE_FAN,
|
||||
)
|
||||
|
||||
from ...addon_common.common.boundvar import BoundBool, BoundInt, BoundFloat, BoundString
|
||||
from ...addon_common.common.profiler import profiler
|
||||
from ...addon_common.common.maths import Point, Point2D, Vec2D, Color, closest_point_segment
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.utils import iter_pairs, delay_exec
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
|
||||
from ...config.options import options, themes
|
||||
|
||||
|
||||
class Tweak(RFTool):
|
||||
name = 'Tweak'
|
||||
description = 'Adjust vertex positions with a smooth brush'
|
||||
icon = 'tweak-icon.png'
|
||||
help = 'tweak.md'
|
||||
shortcut = 'tweak tool'
|
||||
quick_shortcut = 'tweak quick'
|
||||
statusbar = '{{brush}} Tweak\t{{brush alt}} Tweak selection\t{{brush radius}} Brush size\t{{brush strength}} Brush strength\t{{brush falloff}} Brush falloff'
|
||||
ui_config = 'tweak_options.html'
|
||||
|
||||
RFWidget_Default = RFWidget_Default_Factory.create()
|
||||
RFWidget_BrushFalloff = RFWidget_BrushFalloff_Factory.create(
|
||||
'Tweak brush',
|
||||
BoundInt('''options['tweak radius']''', min_value=1),
|
||||
BoundFloat('''options['tweak falloff']''', min_value=0.00, max_value=100.0),
|
||||
BoundFloat('''options['tweak strength']''', min_value=0.01, max_value=1.0),
|
||||
fill_color=themes['tweak'],
|
||||
)
|
||||
|
||||
@RFTool.on_init
|
||||
def init(self):
|
||||
self.rfwidgets = {
|
||||
'default': self.RFWidget_Default(self),
|
||||
'brushstroke': self.RFWidget_BrushFalloff(self),
|
||||
}
|
||||
self.rfwidget = None
|
||||
|
||||
def reset_current_brush(self):
|
||||
options.reset(keys={'tweak radius', 'tweak falloff', 'tweak strength'})
|
||||
self.document.body.getElementById(f'tweak-current-radius').dirty(cause='copied preset to current brush')
|
||||
self.document.body.getElementById(f'tweak-current-strength').dirty(cause='copied preset to current brush')
|
||||
self.document.body.getElementById(f'tweak-current-falloff').dirty(cause='copied preset to current brush')
|
||||
|
||||
def update_preset_name(self, n):
|
||||
name = options[f'tweak preset {n} name']
|
||||
self.document.body.getElementById(f'tweak-preset-{n}-summary').innerText = f'Preset: {name}'
|
||||
|
||||
def copy_current_to_preset(self, n):
|
||||
options[f'tweak preset {n} radius'] = options['tweak radius']
|
||||
options[f'tweak preset {n} strength'] = options['tweak strength']
|
||||
options[f'tweak preset {n} falloff'] = options['tweak falloff']
|
||||
self.document.body.getElementById(f'tweak-preset-{n}-radius').dirty(cause='copied current brush to preset')
|
||||
self.document.body.getElementById(f'tweak-preset-{n}-strength').dirty(cause='copied current brush to preset')
|
||||
self.document.body.getElementById(f'tweak-preset-{n}-falloff').dirty(cause='copied current brush to preset')
|
||||
|
||||
def copy_preset_to_current(self, n):
|
||||
options['tweak radius'] = options[f'tweak preset {n} radius']
|
||||
options['tweak strength'] = options[f'tweak preset {n} strength']
|
||||
options['tweak falloff'] = options[f'tweak preset {n} falloff']
|
||||
self.document.body.getElementById(f'tweak-current-radius').dirty(cause='copied preset to current brush')
|
||||
self.document.body.getElementById(f'tweak-current-strength').dirty(cause='copied preset to current brush')
|
||||
self.document.body.getElementById(f'tweak-current-falloff').dirty(cause='copied preset to current brush')
|
||||
|
||||
@RFTool.on_ui_setup
|
||||
def ui(self):
|
||||
self.update_preset_name(1)
|
||||
self.update_preset_name(2)
|
||||
self.update_preset_name(3)
|
||||
self.update_preset_name(4)
|
||||
|
||||
@RFTool.on_reset
|
||||
def reset(self):
|
||||
self.sel_only = False
|
||||
|
||||
@FSM.on_state('main')
|
||||
def main(self):
|
||||
if self.actions.using_onlymods(['brush', 'brush alt', 'brush radius', 'brush falloff', 'brush strength']):
|
||||
self.set_widget('brushstroke')
|
||||
else:
|
||||
self.set_widget('default')
|
||||
|
||||
if self.rfcontext.actions.pressed(['brush', 'brush alt'], unpress=False):
|
||||
self.sel_only = self.rfcontext.actions.using('brush alt')
|
||||
self.rfcontext.actions.unpress()
|
||||
return 'move'
|
||||
|
||||
if self.rfcontext.actions.pressed('pie menu alt0', unpress=False):
|
||||
def callback(option):
|
||||
if option is None: return
|
||||
self.copy_preset_to_current(option)
|
||||
self.rfcontext.show_pie_menu([
|
||||
(f'Preset: {options["tweak preset 1 name"]}', 1),
|
||||
(f'Preset: {options["tweak preset 2 name"]}', 2),
|
||||
(f'Preset: {options["tweak preset 3 name"]}', 3),
|
||||
(f'Preset: {options["tweak preset 4 name"]}', 4),
|
||||
], callback)
|
||||
return
|
||||
|
||||
|
||||
@FSM.on_state('move', 'can enter')
|
||||
def move_can_enter(self):
|
||||
radius = self.rfwidgets['brushstroke'].get_scaled_radius()
|
||||
nearest = self.rfcontext.nearest_verts_mouse(radius)
|
||||
if not nearest: return False
|
||||
|
||||
@FSM.on_state('move', 'enter')
|
||||
def move_enter(self):
|
||||
# gather options
|
||||
opt_mask_boundary = options['tweak mask boundary']
|
||||
opt_mask_symmetry = options['tweak mask symmetry']
|
||||
opt_mask_occluded = options['tweak mask occluded']
|
||||
opt_mask_selected = options['tweak mask selected']
|
||||
|
||||
Point_to_Point2D = self.rfcontext.Point_to_Point2D
|
||||
hit_pos = self.rfcontext.get_point3D(self.actions.mouse)
|
||||
def get_strength_dist(bmv):
|
||||
return self.rfwidgets['brushstroke'].get_strength_dist((bmv.co - hit_pos).length)
|
||||
is_visible = self.rfcontext.gen_is_visible(occlusion_test_override=True) # always perform occlusion test
|
||||
is_bmvert_visible = lambda bmv: is_visible(bmv.co, bmv.normal)
|
||||
def on_planes(bmv):
|
||||
return self.rfcontext.symmetry_planes_for_point(bmv.co) if opt_mask_symmetry == 'maintain' else None
|
||||
|
||||
# get all verts under brush
|
||||
radius = self.rfwidgets['brushstroke'].get_scaled_radius()
|
||||
nearest = self.rfcontext.nearest_verts_mouse(radius)
|
||||
self.bmverts = [ bmv for (bmv, _) in nearest ]
|
||||
# filter verts based on options
|
||||
if self.sel_only: self.bmverts = [bmv for bmv in self.bmverts if bmv.select]
|
||||
if opt_mask_boundary == 'exclude': self.bmverts = [bmv for bmv in self.bmverts if not bmv.is_on_boundary()]
|
||||
if opt_mask_symmetry == 'exclude': self.bmverts = [bmv for bmv in self.bmverts if not bmv.is_on_symmetry_plane()]
|
||||
if opt_mask_occluded == 'exclude': self.bmverts = [bmv for bmv in self.bmverts if is_bmvert_visible(bmv)]
|
||||
if opt_mask_selected == 'exclude': self.bmverts = [bmv for bmv in self.bmverts if not bmv.select]
|
||||
if opt_mask_selected == 'only': self.bmverts = [bmv for bmv in self.bmverts if bmv.select]
|
||||
|
||||
self.bmvert_data = [
|
||||
(bmv, on_planes(bmv), Point_to_Point2D(bmv.co), Point(bmv.co), get_strength_dist(bmv))
|
||||
for bmv in self.bmverts
|
||||
]
|
||||
|
||||
if opt_mask_boundary == 'slide':
|
||||
self._boundary = [(bme.verts[0].co, bme.verts[1].co) for bme in self.rfcontext.iter_edges() if not bme.is_manifold]
|
||||
else:
|
||||
self._boundary = []
|
||||
|
||||
self.bmfaces = set([f for bmv,_ in nearest for f in bmv.link_faces])
|
||||
self.mousedown = self.rfcontext.actions.mouse
|
||||
self._timer = self.actions.start_timer(120.0)
|
||||
|
||||
self.rfcontext.split_target_visualization(verts=self.bmverts)
|
||||
self.rfcontext.undo_push('tweak move')
|
||||
|
||||
@FSM.on_state('move')
|
||||
def move(self):
|
||||
if self.rfcontext.actions.released(['brush','brush alt']):
|
||||
return 'main'
|
||||
|
||||
if self.rfcontext.actions.pressed('cancel'):
|
||||
self.rfcontext.undo_cancel()
|
||||
self.actions.unuse('brush', ignoremods=True, ignoremulti=True)
|
||||
self.actions.unuse('brush alt', ignoremods=True, ignoremulti=True)
|
||||
return 'main'
|
||||
|
||||
@RFTool.on_events('mouse move')
|
||||
@RFTool.once_per_frame
|
||||
@FSM.onlyinstate('move')
|
||||
@RFTool.dirty_when_done
|
||||
def move_doit(self):
|
||||
if self.actions.mouse_prev == self.actions.mouse: return
|
||||
|
||||
opt_mask_boundary = options['tweak mask symmetry']
|
||||
opt_mask_boundary = options['tweak mask boundary']
|
||||
|
||||
delta = Vec2D(self.rfcontext.actions.mouse - self.mousedown)
|
||||
set2D_vert = self.rfcontext.set2D_vert
|
||||
snap_vert = self.rfcontext.snap_vert
|
||||
update_face_normal = self.rfcontext.update_face_normal
|
||||
|
||||
for (bmv, sympl, xy, xyz, strength) in self.bmvert_data:
|
||||
if not bmv.is_valid: continue
|
||||
co2D = xy + delta * strength
|
||||
match options['tweak mode']:
|
||||
case 'snap':
|
||||
dist = self.rfcontext.Point_to_depth(xyz)
|
||||
bmv.co = self.rfcontext.Point2D_to_Point(co2D, dist)
|
||||
snap_vert(bmv, snap_to_symmetry=sympl)
|
||||
case 'raycast':
|
||||
set2D_vert(bmv, co2D, sympl)
|
||||
case _:
|
||||
assert False, f'Invalid tweak mode {options["tweak mode"]}'
|
||||
|
||||
|
||||
if opt_mask_boundary == 'slide' and bmv.is_on_boundary():
|
||||
co = bmv.co
|
||||
p, d = None, None
|
||||
for (v0, v1) in self._boundary:
|
||||
p_ = closest_point_segment(co, v0, v1)
|
||||
d_ = (p_ - co).length
|
||||
if p is None or d_ < d: p, d = p_, d_
|
||||
if p is not None:
|
||||
bmv.co = p
|
||||
self.rfcontext.snap_vert(bmv)
|
||||
|
||||
for bmf in self.bmfaces:
|
||||
if not bmf.is_valid: continue
|
||||
update_face_normal(bmf)
|
||||
|
||||
tag_redraw_all('Tweak mouse move')
|
||||
|
||||
@FSM.on_state('move', 'exit')
|
||||
def move_exit(self):
|
||||
self.rfcontext.clear_split_target_visualization()
|
||||
self._timer.done()
|
||||
@@ -0,0 +1,220 @@
|
||||
<details id='tweak-options'>
|
||||
<summary>Tweak</summary>
|
||||
<div class="contents">
|
||||
<div class="collection" id="tweak-mode">
|
||||
<h1>Mode</h1>
|
||||
<div class="contents">
|
||||
<label class="half-size">
|
||||
<input type="radio" title="Raycast tweaked vertices to sources (tweak in screen space)" value="raycast" checked="BoundString('''options['tweak mode']''')" name='tweak-mode'>
|
||||
Raycast
|
||||
</label>
|
||||
<label class="half-size">
|
||||
<input type="radio" title="Snap tweaked vertices to sources (tweak in world space)" value="snap" checked="BoundString('''options['tweak mode']''')" name='tweak-mode'>
|
||||
Snap
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class='collection' id='tweak-masking'>
|
||||
<h1>Masking Options</h1>
|
||||
<div class='collection'>
|
||||
<h1>Boundary</h1>
|
||||
<div class='contents'>
|
||||
<label class='third-size'>
|
||||
<input type="radio" title='Tweak vertices not along boundary' value='exclude' checked="BoundString('''options['tweak mask boundary']''')" name='tweak-boundary'>
|
||||
Exclude
|
||||
</label>
|
||||
<label class='third-size'>
|
||||
<input type="radio" title='Tweak vertices along boundary, but move them by sliding along boundary' value='slide' checked="BoundString('''options['tweak mask boundary']''')" name='tweak-boundary'>
|
||||
Slide
|
||||
</label>
|
||||
<label class="third-size">
|
||||
<input type="radio" title="Tweak all vertices within brush, regardless of being along boundary" value='include' checked="BoundString('''options['tweak mask boundary']''')" name='tweak-boundary'>
|
||||
Include
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class='collection'>
|
||||
<h1>Symmetry</h1>
|
||||
<div class='contents'>
|
||||
<label class='third-size'>
|
||||
<input type="radio" title='Tweak vertices not along symmetry plane' value='exclude' checked="BoundString('''options['tweak mask symmetry']''')" name='tweak-symmetry'>
|
||||
Exclude
|
||||
</label>
|
||||
<label class='third-size'>
|
||||
<input type="radio" title='Tweak vertices along symmetry plane, but move them by sliding along symmetry plane' value='maintain' checked="BoundString('''options['tweak mask symmetry']''')" name='tweak-symmetry'>
|
||||
Slide
|
||||
</label>
|
||||
<label class='third-size'>
|
||||
<input type="radio" title='Tweak all vertices within brush, regardless of being along symmetry plane' value='include' checked="BoundString('''options['tweak mask symmetry']''')" name='tweak-symmetry'>
|
||||
Include
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class='collection'>
|
||||
<h1>Occluded</h1>
|
||||
<div class='contents'>
|
||||
<label class='half-size'>
|
||||
<input type="radio" title="Tweak only vertices not occluded by other geometry" value='exclude' checked="BoundString('''options['tweak mask occluded']''')" name='tweak-occluded'>
|
||||
Exclude
|
||||
</label>
|
||||
<label class='half-size'>
|
||||
<input type="radio" title="Tweak all vertices within brush, regardless of occlusion" value='include' checked="BoundString('''options['tweak mask occluded']''')" name='tweak-occluded'>
|
||||
Include
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collection">
|
||||
<h1>Selected</h1>
|
||||
<div class="contents">
|
||||
<label class="third-size">
|
||||
<input type="radio" title='Tweak only unselected vertices' value='exclude' checked="BoundString('''options['tweak mask selected']''')" name='tweak-selected'>
|
||||
Exclude
|
||||
</label>
|
||||
<label class="third-size">
|
||||
<input type="radio" title='Tweak only selected vertices' value='only' checked="BoundString('''options['tweak mask selected']''')" name='tweak-selected'>
|
||||
Only
|
||||
</label>
|
||||
<label class="third-size">
|
||||
<input type="radio" title='Tweak all vertices within brush, regardless of selection' value='all' checked="BoundString('''options['tweak mask selected']''')" name='tweak-selected'>
|
||||
All
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details>
|
||||
<summary>Brush Options</summary>
|
||||
<div class="contents">
|
||||
<div class="collection" id='tweak-current-brush'>
|
||||
<h1>Current</h1>
|
||||
<div class="contents">
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-current-radius'>Size</label>
|
||||
<input type="number" title="Adjust brush size" id='tweak-current-radius' value="self.rfwidgets['brushstroke'].get_radius_boundvar()">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-current-strength'>Strength</label>
|
||||
<input type="number" title="Adjust brush strength" id='tweak-current-strength' value="self.rfwidgets['brushstroke'].get_strength_boundvar()">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-current-falloff'>Falloff</label>
|
||||
<input type="number" title="Adjust brush falloff" id='tweak-current-falloff' value="self.rfwidgets['brushstroke'].get_falloff_boundvar()">
|
||||
</div>
|
||||
<button title="Reset brush options to defaults" on_mouseclick="self.reset_current_brush()">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<details id='tweak-preset-1'>
|
||||
<summary id='tweak-preset-1-summary'>Preset: Preset 1</summary>
|
||||
<div class="contents">
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-preset-1-name'>Name</label>
|
||||
<input type="text" title="Change name of preset" id='tweak-preset-1-name' value="BoundString('''options['tweak preset 1 name']''')" on_change="self.update_preset_name(1)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-preset-1-radius'>Size</label>
|
||||
<input type="number" title="Adjust brush size" id='tweak-preset-1-radius' value="BoundInt('''options['tweak preset 1 radius']''', min_value=1)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-preset-1-strength'>Strength</label>
|
||||
<input type="number" title="Adjust brush strength" id='tweak-preset-1-strength' value="BoundFloat('''options['tweak preset 1 strength']''', min_value=0.01, max_value=1.0)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-preset-1-falloff'>Falloff</label>
|
||||
<input type="number" title="Adjust brush falloff" id='tweak-preset-1-falloff' value="BoundFloat('''options['tweak preset 1 falloff']''', min_value=0.0, max_value=100.0)">
|
||||
</div>
|
||||
<div class='collection'>
|
||||
<h1>Copy Brush Settings</h1>
|
||||
<div class='contents'>
|
||||
<button title="Copy current brush settings to this preset" on_mouseclick="self.copy_current_to_preset(1)">Current to Preset</button>
|
||||
<button title="Copy this preset to current brush settings" on_mouseclick="self.copy_preset_to_current(1)">Preset to Current</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details id='tweak-preset-2'>
|
||||
<summary id='tweak-preset-2-summary'>Preset: Preset 2</summary>
|
||||
<div class="contents">
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-preset-2-name'>Name</label>
|
||||
<input type="text" title="Change name of preset" id='tweak-preset-2-name' value="BoundString('''options['tweak preset 2 name']''')" on_change="self.update_preset_name(2)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-preset-2-radius'>Size</label>
|
||||
<input type="number" title="Adjust brush size" id='tweak-preset-2-radius' value="BoundInt('''options['tweak preset 2 radius']''', min_value=1)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-preset-2-strength'>Strength</label>
|
||||
<input type="number" title="Adjust brush strength" id='tweak-preset-2-strength' value="BoundFloat('''options['tweak preset 2 strength']''', min_value=0.01, max_value=1.0)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-preset-2-falloff'>Falloff</label>
|
||||
<input type="number" title="Adjust brush falloff" id='tweak-preset-2-falloff' value="BoundFloat('''options['tweak preset 2 falloff']''', min_value=0.0, max_value=100.0)">
|
||||
</div>
|
||||
<div class='collection'>
|
||||
<h1>Copy Brush Settings</h1>
|
||||
<div class='contents'>
|
||||
<button title="Copy current brush settings to this preset" on_mouseclick="self.copy_current_to_preset(2)">Current to Preset</button>
|
||||
<button title="Copy this preset to current brush settings" on_mouseclick="self.copy_preset_to_current(2)">Preset to Current</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details id='tweak-preset-3'>
|
||||
<summary id='tweak-preset-3-summary'>Preset: Preset 3</summary>
|
||||
<div class="contents">
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-preset-3-name'>Name</label>
|
||||
<input type="text" title="Change name of preset" id='tweak-preset-3-name' value="BoundString('''options['tweak preset 3 name']''')" on_change="self.update_preset_name(3)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-preset-3-radius'>Size</label>
|
||||
<input type="number" title="Adjust brush size" id='tweak-preset-3-radius' value="BoundInt('''options['tweak preset 3 radius']''', min_value=1)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-preset-3-strength'>Strength</label>
|
||||
<input type="number" title="Adjust brush strength" id='tweak-preset-3-strength' value="BoundFloat('''options['tweak preset 3 strength']''', min_value=0.01, max_value=1.0)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-preset-3-falloff'>Falloff</label>
|
||||
<input type="number" title="Adjust brush falloff" id='tweak-preset-3-falloff' value="BoundFloat('''options['tweak preset 3 falloff']''', min_value=0.0, max_value=100.0)">
|
||||
</div>
|
||||
<div class='collection'>
|
||||
<h1>Copy Brush Settings</h1>
|
||||
<div class='contents'>
|
||||
<button title="Copy current brush settings to this preset" on_mouseclick="self.copy_current_to_preset(3)">Current to Preset</button>
|
||||
<button title="Copy this preset to current brush settings" on_mouseclick="self.copy_preset_to_current(3)">Preset to Current</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details id='tweak-preset-4'>
|
||||
<summary id='tweak-preset-4-summary'>Preset: Preset 4</summary>
|
||||
<div class="contents">
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-preset-4-name'>Name</label>
|
||||
<input type="text" title="Change name of preset" id='tweak-preset-4-name' value="BoundString('''options['tweak preset 4 name']''')" on_change="self.update_preset_name(4)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-preset-4-radius'>Size</label>
|
||||
<input type="number" title="Adjust brush size" id='tweak-preset-4-radius' value="BoundInt('''options['tweak preset 4 radius']''', min_value=1)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-preset-4-strength'>Strength</label>
|
||||
<input type="number" title="Adjust brush strength" id='tweak-preset-4-strength' value="BoundFloat('''options['tweak preset 4 strength']''', min_value=0.01, max_value=1.0)">
|
||||
</div>
|
||||
<div class='labeled-input-text'>
|
||||
<label for='tweak-preset-4-falloff'>Falloff</label>
|
||||
<input type="number" title="Adjust brush falloff" id='tweak-preset-4-falloff' value="BoundFloat('''options['tweak preset 4 falloff']''', min_value=0.0, max_value=100.0)">
|
||||
</div>
|
||||
<div class='collection'>
|
||||
<h1>Copy Brush Settings</h1>
|
||||
<div class='contents'>
|
||||
<button title="Copy current brush settings to this preset" on_mouseclick="self.copy_current_to_preset(4)">Current to Preset</button>
|
||||
<button title="Copy this preset to current brush settings" on_mouseclick="self.copy_preset_to_current(4)">Preset to Current</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
@@ -0,0 +1,170 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
from ..addon_common.common.debug import debugger
|
||||
from ..addon_common.common.drawing import Cursors
|
||||
from ..addon_common.common.fsm import FSM
|
||||
from ..addon_common.common.drawing import DrawCallbacks
|
||||
from ..addon_common.common.functools import find_fns
|
||||
|
||||
def rfwidget_callback_decorator(event, fn):
|
||||
if not hasattr(fn, '_rfwidget_callback'):
|
||||
fn._rfwidget_callback = []
|
||||
fn._rfwidget_callback += [event]
|
||||
return fn
|
||||
|
||||
|
||||
class RFWidget:
|
||||
'''
|
||||
Assumes that direct subclass will have singleton instance (shared FSM among all instances of that subclass and any subclasses)
|
||||
'''
|
||||
registry = []
|
||||
|
||||
@classmethod
|
||||
def __init_subclass__(cls, *args, **kwargs):
|
||||
# print('rfwidget subclass', cls, super(cls))
|
||||
if not hasattr(cls, '_rfwidget_index'):
|
||||
# add cls to registry (might get updated later) and add FSM,Draw
|
||||
cls._rfwidget_index = len(RFWidget.registry)
|
||||
RFWidget.registry.append(cls)
|
||||
else:
|
||||
# update registry, but do not add new FSM
|
||||
RFWidget.registry[cls._rfwidget_index] = cls
|
||||
super().__init_subclass__(*args, **kwargs)
|
||||
|
||||
|
||||
#####################################################
|
||||
# function decorators for different events
|
||||
|
||||
@staticmethod
|
||||
def on_init(fn): return rfwidget_callback_decorator('init', fn)
|
||||
@staticmethod
|
||||
def on_reset(fn): return rfwidget_callback_decorator('reset', fn)
|
||||
@staticmethod
|
||||
def on_timer(fn): return rfwidget_callback_decorator('timer', fn)
|
||||
@staticmethod
|
||||
def on_target_change(fn): return rfwidget_callback_decorator('target change', fn)
|
||||
@staticmethod
|
||||
def on_view_change(fn): return rfwidget_callback_decorator('view change', fn)
|
||||
@staticmethod
|
||||
def on_action(action_name):
|
||||
def wrapper(fn):
|
||||
nonlocal action_name
|
||||
fn._rfwidget_action_name = action_name
|
||||
return rfwidget_callback_decorator('action', fn)
|
||||
return wrapper
|
||||
@staticmethod
|
||||
def on_actioning(action_name):
|
||||
def wrapper(fn):
|
||||
nonlocal action_name
|
||||
fn._rfwidget_action_name = action_name
|
||||
return rfwidget_callback_decorator('actioning', fn)
|
||||
return wrapper
|
||||
|
||||
def __init__(self, rftool, *, start='main', reset_state=None, **kwargs):
|
||||
self.rftool = rftool
|
||||
self.rfcontext = rftool.rfcontext
|
||||
self.actions = rftool.rfcontext.actions
|
||||
self.redraw_on_mouse = False
|
||||
self._gather_callbacks()
|
||||
self._fsm = FSM(self, start=start, reset_state=reset_state)
|
||||
self._draw = DrawCallbacks(self)
|
||||
self._callback_widget('init', **kwargs)
|
||||
# self._init_action_callback()
|
||||
self._reset()
|
||||
|
||||
def _fsm_in_main(self):
|
||||
return self._fsm.state in {'main'}
|
||||
|
||||
def _callback_widget(self, event, *args, **kwargs):
|
||||
if event != 'timer':
|
||||
#print('callback', self, event, self._widget_callbacks.get(event, []))
|
||||
pass
|
||||
if event not in self._widget_callbacks: return
|
||||
for fn in self._widget_callbacks[event]:
|
||||
fn(self, *args, **kwargs)
|
||||
|
||||
def _callback_tool(self, event, action_name, *args, **kwargs):
|
||||
if event != 'timer':
|
||||
#print('callback', self, event, self._tool_callbacks.get(event, []))
|
||||
pass
|
||||
if event not in self._tool_callbacks: return
|
||||
for fn in self._tool_callbacks[event]:
|
||||
if fn._rfwidget_action_name != action_name: continue
|
||||
fn(self.rftool, *args, **kwargs)
|
||||
|
||||
def _gather_callbacks(self):
|
||||
widget_fns = find_fns(self, '_rfwidget_callback')
|
||||
self._widget_callbacks = {
|
||||
mode: [fn for (modes, fn) in widget_fns if mode in modes]
|
||||
for mode in [
|
||||
'init', # called when RF starts up
|
||||
'reset', # called when RF switches into tool or undo/redo
|
||||
'timer', # called every timer interval
|
||||
'target change', # called whenever rftarget has changed (selection or edited)
|
||||
'view change', # called whenever view has changed
|
||||
]
|
||||
}
|
||||
rftool_fns = find_fns(self.rftool, '_rfwidget_callback')
|
||||
self._tool_callbacks = {
|
||||
mode: [fn for (modes, fn) in rftool_fns if mode in modes]
|
||||
for mode in [
|
||||
'action', # called when user performs widget action, per instance!
|
||||
'actioning', # called when user is performing widget action, per instance!
|
||||
]
|
||||
}
|
||||
|
||||
def callback_actions(self, action_name, *args, **kwargs):
|
||||
self._callback_tool('action', action_name, *args, **kwargs)
|
||||
|
||||
def callback_actioning(self, action_name, *args, **kwargs):
|
||||
self._callback_tool('actioning', action_name, *args, **kwargs)
|
||||
|
||||
def _reset(self):
|
||||
self._fsm.force_reset()
|
||||
self._callback_widget('reset')
|
||||
self._update_all()
|
||||
|
||||
def _fsm_update(self):
|
||||
return self._fsm.update()
|
||||
|
||||
def _update_all(self):
|
||||
self._callback_widget('timer')
|
||||
self._callback_widget('target change')
|
||||
self._callback_widget('view change')
|
||||
|
||||
@staticmethod
|
||||
def dirty_when_done(fn):
|
||||
def wrapper(*args, **kwargs):
|
||||
ret = fn(*args, **kwargs)
|
||||
RFWidget.rfcontext.dirty()
|
||||
return ret
|
||||
return wrapper
|
||||
|
||||
def set_cursor(self):
|
||||
Cursors.set(self.rfw_cursor)
|
||||
|
||||
def inactive_passthrough(self): pass
|
||||
|
||||
def _draw_pre3d(self): self._draw.pre3d()
|
||||
def _draw_post3d(self): self._draw.post3d()
|
||||
def _draw_post2d(self): self._draw.post2d()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
__all__ = []
|
||||
@@ -0,0 +1,307 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
import gpu
|
||||
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
from ..rfwidget import RFWidget
|
||||
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.boundvar import BoundBool, BoundInt, BoundFloat
|
||||
from ...addon_common.common.drawing import DrawCallbacks
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common import gpustate
|
||||
from ...addon_common.common.maths import Vec, Point, Point2D, Direction, Color, Vec2D
|
||||
from ...config.options import themes, options
|
||||
|
||||
|
||||
class RFWidget_BrushFalloff_Factory:
|
||||
'''
|
||||
This is a class factory. It is needed, because the FSM is shared across instances.
|
||||
RFTools might need to share RFWidgets that are independent of each other.
|
||||
'''
|
||||
|
||||
@staticmethod
|
||||
def create(action_name, radius, falloff, strength, fill_color=Color((1,1,1,1)), outer_color=Color((1,1,1,1)), inner_color=Color((1,1,1,0.5)), below_alpha=Color((1,1,1,0.25))):
|
||||
class RFWidget_BrushFalloff(RFWidget):
|
||||
rfw_name = 'Brush Falloff'
|
||||
rfw_cursor = 'CROSSHAIR'
|
||||
|
||||
@RFWidget.on_init
|
||||
def init(self):
|
||||
self.action_name = action_name
|
||||
self.outer_color = outer_color
|
||||
self.inner_color = inner_color
|
||||
self.fill_color = fill_color
|
||||
self.color_mult_below = below_alpha
|
||||
self.redraw_on_mouse = True
|
||||
self.last_mouse = None
|
||||
self.last_view = None
|
||||
self.hit = False
|
||||
self.hit_scale = 1.0
|
||||
|
||||
@FSM.on_state('main')
|
||||
def main(self):
|
||||
self.update_mouse()
|
||||
|
||||
if self.rfcontext.actions.pressed('brush radius increase'):
|
||||
self.radius += 10
|
||||
tag_redraw_all('BrushFalloff increase radius')
|
||||
return
|
||||
if self.rfcontext.actions.pressed('brush radius decrease'):
|
||||
self.radius -= 10
|
||||
tag_redraw_all('BrushFalloff decrease radius')
|
||||
return
|
||||
|
||||
if self.rfcontext.actions.pressed('brush radius'):
|
||||
self._dist_to_var_fn = self.dist_to_radius
|
||||
self._var_to_dist_fn = self.radius_to_dist
|
||||
return 'change'
|
||||
if self.rfcontext.actions.pressed('brush strength'):
|
||||
self._dist_to_var_fn = self.dist_to_strength
|
||||
self._var_to_dist_fn = self.strength_to_dist
|
||||
return 'change'
|
||||
if self.rfcontext.actions.pressed('brush falloff'):
|
||||
self._dist_to_var_fn = self.dist_to_falloff
|
||||
self._var_to_dist_fn = self.falloff_to_dist
|
||||
return 'change'
|
||||
|
||||
@FSM.on_state('change', 'enter')
|
||||
def change_enter(self):
|
||||
dist = self._var_to_dist_fn()
|
||||
actions = self.rfcontext.actions
|
||||
self._change_pre = dist
|
||||
self._change_center = actions.mouse - Vec2D((dist, 0))
|
||||
self._timer = self.actions.start_timer(120)
|
||||
tag_redraw_all('BrushFalloff change_enter')
|
||||
|
||||
@FSM.on_state('change')
|
||||
def change(self):
|
||||
assert self._dist_to_var_fn
|
||||
actions = self.rfcontext.actions
|
||||
|
||||
if actions.pressed('cancel', ignoremods=True, ignoredrag=True):
|
||||
self._dist_to_var_fn(self._change_pre)
|
||||
return 'main'
|
||||
if actions.pressed({'confirm', 'confirm drag'}, ignoremods=True):
|
||||
return 'main'
|
||||
|
||||
dist = (self._change_center - actions.mouse).length
|
||||
self._dist_to_var_fn(dist)
|
||||
|
||||
@FSM.on_state('change', 'exit')
|
||||
def change_exit(self):
|
||||
self._dist_to_var_fn = None
|
||||
self._var_to_dist_fn = None
|
||||
self._timer.done()
|
||||
tag_redraw_all('BrushFalloff change_exit')
|
||||
|
||||
@DrawCallbacks.on_draw('post3d')
|
||||
@FSM.onlyinstate('main')
|
||||
def draw_brush(self):
|
||||
if not self.hit: return
|
||||
|
||||
ff = math.pow(0.5, 1.0 / max(self.falloff, 0.0001))
|
||||
p, n = self.hit_p, self.hit_n
|
||||
ro = self.radius * self.hit_scale
|
||||
ri = ro * ff
|
||||
rm = (ro + ri) / 2.0
|
||||
co, ci, cc = self.outer_color, self.inner_color, self.fill_color * self.fill_color_scale
|
||||
|
||||
fwd = Direction(self.rfcontext.Vec_forward()) * (self.hit_depth * 0.0005)
|
||||
|
||||
# draw below
|
||||
gpustate.depth_mask(False)
|
||||
gpustate.depth_test('GREATER')
|
||||
Globals.drawing.draw3D_circle(p-fwd*1.0, rm, cc * self.color_mult_below, n=n, width=ro - ri)
|
||||
Globals.drawing.draw3D_circle(p-fwd*2.0, ro, co * self.color_mult_below, n=n, width=2*self.hit_scale)
|
||||
Globals.drawing.draw3D_circle(p-fwd*2.0, ri, ci * self.color_mult_below, n=n, width=2*self.hit_scale)
|
||||
|
||||
# draw above
|
||||
gpustate.depth_test('LESS_EQUAL')
|
||||
Globals.drawing.draw3D_circle(p-fwd*1.0, rm, cc, n=n, width=ro - ri)
|
||||
Globals.drawing.draw3D_circle(p-fwd*2.0, ro, co, n=n, width=2*self.hit_scale)
|
||||
Globals.drawing.draw3D_circle(p-fwd*2.0, ri, ci, n=n, width=2*self.hit_scale)
|
||||
|
||||
# reset
|
||||
gpustate.depth_test('LESS_EQUAL')
|
||||
gpustate.depth_mask(True)
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate('change')
|
||||
def draw_brush_sizing(self):
|
||||
#r = (self._change_center - self.actions.mouse).length
|
||||
r = self.radius
|
||||
co = self.outer_color
|
||||
ci = self.inner_color
|
||||
cc = self.fill_color * self.fill_color_scale
|
||||
ff = math.pow(0.5, 1.0 / max(self.falloff, 0.0001))
|
||||
fs = (1-ff) * self.radius
|
||||
Globals.drawing.draw2D_circle(self._change_center, r-fs/2, cc, width=fs)
|
||||
Globals.drawing.draw2D_circle(self._change_center, r, co, width=1)
|
||||
Globals.drawing.draw2D_circle(self._change_center, r*ff, ci, width=1)
|
||||
|
||||
|
||||
##################
|
||||
# getters
|
||||
|
||||
def get_scaled_radius(self):
|
||||
return self.hit_scale * self.radius
|
||||
|
||||
def get_scaled_size(self):
|
||||
return self.hit_scale * self.size
|
||||
|
||||
def get_strength_dist(self, dist:float):
|
||||
return max(0.0, min(1.0, (1.0 - math.pow(dist / self.get_scaled_radius(), self.falloff)))) * self.strength
|
||||
|
||||
def get_strength_Point(self, point:Point):
|
||||
if not self.hit_p: return 0.0
|
||||
return self.get_strength_dist((point - self.hit_p).length)
|
||||
|
||||
|
||||
###################
|
||||
# radius
|
||||
|
||||
@property
|
||||
def radius(self):
|
||||
return radius.get()
|
||||
@radius.setter
|
||||
def radius(self, v):
|
||||
radius.set(max(1, float(v)))
|
||||
|
||||
def radius_to_dist(self):
|
||||
return self.radius
|
||||
|
||||
def dist_to_radius(self, d):
|
||||
self.radius = max(1, int(d))
|
||||
|
||||
def radius_gettersetter(self):
|
||||
def getter():
|
||||
return int(self.radius)
|
||||
def setter(v):
|
||||
self.radius = max(1, int(v))
|
||||
return (getter, setter)
|
||||
|
||||
def get_radius_boundvar(self):
|
||||
return radius
|
||||
|
||||
##################
|
||||
# strength
|
||||
|
||||
@property
|
||||
def strength(self):
|
||||
return strength.get()
|
||||
@strength.setter
|
||||
def strength(self, v):
|
||||
# print('strength', v)
|
||||
strength.set(max(0.01, min(1.0, float(v))))
|
||||
|
||||
def strength_to_dist(self):
|
||||
return self.radius * (1.0 - self.strength)
|
||||
|
||||
def dist_to_strength(self, d):
|
||||
self.strength = 1.0 - max(0.01, min(1.0, d / self.radius))
|
||||
|
||||
def strength_gettersetter(self):
|
||||
def getter():
|
||||
return int(self.strength * 100)
|
||||
def setter(v):
|
||||
self.strength = max(1, min(100, v)) / 100
|
||||
return (getter, setter)
|
||||
|
||||
def get_strength_boundvar(self):
|
||||
return strength
|
||||
|
||||
##################
|
||||
# falloff
|
||||
|
||||
@property
|
||||
def falloff(self):
|
||||
return falloff.get()
|
||||
@falloff.setter
|
||||
def falloff(self, v):
|
||||
# print('falloff', v)
|
||||
falloff.set(max(0.0, min(100.0, float(v))))
|
||||
|
||||
def falloff_to_dist(self):
|
||||
return self.radius * math.pow(0.5, 1.0 / max(self.falloff, 0.0001))
|
||||
|
||||
def dist_to_falloff(self, d):
|
||||
self.falloff = math.log(0.5) / math.log(max(0.01, min(0.99, d / self.radius)))
|
||||
|
||||
def falloff_gettersetter(self):
|
||||
def getter():
|
||||
return int(100 * math.pow(0.5, 1.0 / max(self.falloff, 0.0001)))
|
||||
def setter(v):
|
||||
self.falloff = math.log(0.5) / math.log(max(0.01, min(0.99, v / 100)))
|
||||
pass
|
||||
return (getter, setter)
|
||||
|
||||
def get_falloff_boundvar(self):
|
||||
return falloff
|
||||
|
||||
##################
|
||||
# fill_color_scale
|
||||
|
||||
@property
|
||||
def fill_color_scale(self):
|
||||
return Color((1, 1, 1, self.strength * (options['brush max alpha'] - options['brush min alpha']) + options['brush min alpha']))
|
||||
|
||||
##################
|
||||
# mouse
|
||||
|
||||
def update_mouse(self):
|
||||
recompute = False
|
||||
recompute |= (self.last_mouse != self.actions.mouse)
|
||||
recompute |= (self.last_view != self.rfcontext.get_view_version())
|
||||
if not recompute: return
|
||||
self.last_mouse = self.actions.mouse
|
||||
self.last_view = self.rfcontext.get_view_version()
|
||||
|
||||
self.hit = False
|
||||
|
||||
# figure out how much to scale so that the brush drawn in 3D appears the same size on screen
|
||||
xy = self.actions.mouse
|
||||
p,n,_,_ = self.rfcontext.raycast_sources_mouse()
|
||||
if not p: return
|
||||
depth = self.rfcontext.Point_to_depth(p)
|
||||
if not depth: return
|
||||
scale = self.rfcontext.size2D_to_size(1.0, depth)
|
||||
if scale is None: return
|
||||
|
||||
rmat = Matrix.Rotation(Direction.Z.angle(n), 4, Direction.Z.cross(n))
|
||||
|
||||
self.hit = True
|
||||
self.hit_scale = scale
|
||||
self.hit_p = p
|
||||
self.hit_n = n
|
||||
self.hit_depth = depth
|
||||
self.hit_x = Vec(rmat @ Direction.X)
|
||||
self.hit_y = Vec(rmat @ Direction.Y)
|
||||
self.hit_z = Vec(rmat @ Direction.Z)
|
||||
self.hit_rmat = rmat
|
||||
|
||||
return RFWidget_BrushFalloff
|
||||
@@ -0,0 +1,246 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import math
|
||||
import random
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
from ..rfwidget import RFWidget
|
||||
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common import gpustate
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.drawing import DrawCallbacks
|
||||
from ...addon_common.common.boundvar import BoundBool, BoundInt, BoundFloat
|
||||
from ...addon_common.common.maths import Vec, Point, Point2D, Direction, Color, Vec2D
|
||||
from ...config.options import themes
|
||||
|
||||
|
||||
class RFWidget_BrushStroke_Factory:
|
||||
'''
|
||||
This is a class factory. It is needed, because the FSM is shared across instances.
|
||||
RFTools might need to share RFWidgets that are independent of each other.
|
||||
'''
|
||||
|
||||
@staticmethod
|
||||
def create(action_name, radius, outer_border_color=Color((0,0,0,0.5)), outer_color=Color((1,1,1,1)), inner_color=Color((1,1,1,0.5)), below_alpha=Color((1,1,1,0.25))):
|
||||
class RFWidget_BrushStroke(RFWidget):
|
||||
rfw_name = 'Brush Stroke'
|
||||
rfw_cursor = 'CROSSHAIR'
|
||||
|
||||
@RFWidget.on_init
|
||||
def init(self):
|
||||
self.action_name = action_name
|
||||
self.stroke2D = []
|
||||
self.tightness = 0.95
|
||||
self.redraw_on_mouse = True
|
||||
self.sizing_pos = None
|
||||
self.outer_border_color = outer_border_color
|
||||
self.outer_color = outer_color
|
||||
self.inner_color = inner_color
|
||||
self.color_mult_below = below_alpha
|
||||
self.last_mouse = None
|
||||
self.last_view = None
|
||||
self.hit = False
|
||||
self.hit_scale = 1.0
|
||||
self.inner_radius = 10.0
|
||||
|
||||
@FSM.on_state('main', 'enter')
|
||||
def modal_main_enter(self):
|
||||
self.rfw_cursor = 'CROSSHAIR'
|
||||
tag_redraw_all('BrushStroke main_enter')
|
||||
|
||||
@FSM.on_state('main')
|
||||
def modal_main(self):
|
||||
self.update_mouse()
|
||||
|
||||
if self.actions.pressed('insert'):
|
||||
return 'stroking'
|
||||
|
||||
if self.rfcontext.actions.pressed('brush radius increase'):
|
||||
self.radius += 10
|
||||
tag_redraw_all('BrushStroke increase radius')
|
||||
return
|
||||
if self.rfcontext.actions.pressed('brush radius decrease'):
|
||||
self.radius -= 10
|
||||
tag_redraw_all('BrushStroke decrease radius')
|
||||
return
|
||||
|
||||
if self.actions.pressed('brush radius'):
|
||||
return 'brush sizing'
|
||||
|
||||
def inactive_passthrough(self):
|
||||
if self.actions.pressed('brush radius'):
|
||||
self._fsm.force_set_state('brush sizing')
|
||||
return True
|
||||
|
||||
@FSM.on_state('stroking', 'enter')
|
||||
def modal_line_enter(self):
|
||||
xy = self.actions.mouse
|
||||
p,n,_,_ = self.rfcontext.raycast_sources_mouse()
|
||||
if p: xy = self.rfcontext.Point_to_Point2D(p)
|
||||
self.stroke2D = [xy]
|
||||
tag_redraw_all('BrushStroke line_enter')
|
||||
|
||||
@FSM.on_state('stroking')
|
||||
def modal_line(self):
|
||||
self.update_mouse()
|
||||
|
||||
if self.actions.released('insert'):
|
||||
# TODO: tessellate the last steps?
|
||||
xy = self.actions.mouse
|
||||
p,n,_,_ = self.rfcontext.raycast_sources_mouse()
|
||||
if p: xy = self.rfcontext.Point_to_Point2D(p)
|
||||
self.stroke2D.append(xy)
|
||||
self.callback_actions(self.action_name)
|
||||
return 'main'
|
||||
|
||||
if self.actions.pressed('cancel'):
|
||||
self.stroke2D = []
|
||||
self.actions.unuse('insert', ignoremods=True, ignoremulti=True)
|
||||
return 'main'
|
||||
|
||||
xy = self.actions.mouse
|
||||
p,n,_,_ = self.rfcontext.raycast_sources_mouse()
|
||||
if p: xy = self.rfcontext.Point_to_Point2D(p)
|
||||
lpos, cpos = self.stroke2D[-1], xy
|
||||
npos = lpos + (cpos - lpos) * (1 - self.tightness)
|
||||
self.stroke2D.append(npos)
|
||||
tag_redraw_all('BrushStroke line')
|
||||
self.callback_actioning(self.action_name)
|
||||
|
||||
@FSM.on_state('stroking', 'exit')
|
||||
def modal_line_exit(self):
|
||||
tag_redraw_all('BrushStroke line_exit')
|
||||
|
||||
@FSM.on_state('brush sizing', 'enter')
|
||||
def modal_brush_sizing_enter(self):
|
||||
if self.actions.mouse.x > self.actions.size.x / 2:
|
||||
self.sizing_pos = self.actions.mouse - Vec2D((self.radius, 0))
|
||||
else:
|
||||
self.sizing_pos = self.actions.mouse + Vec2D((self.radius, 0))
|
||||
self.rfw_cursor = 'MOVE_X'
|
||||
tag_redraw_all('BrushStroke brush_sizing_enter')
|
||||
|
||||
@FSM.on_state('brush sizing')
|
||||
def modal_brush_sizing(self):
|
||||
if self.actions.pressed('confirm'):
|
||||
self.radius = (self.sizing_pos - self.actions.mouse).length
|
||||
return 'main'
|
||||
if self.actions.pressed('cancel'):
|
||||
return 'main'
|
||||
|
||||
|
||||
###################
|
||||
# radius
|
||||
|
||||
@property
|
||||
def radius(self):
|
||||
return radius.get()
|
||||
@radius.setter
|
||||
def radius(self, v):
|
||||
radius.set(max(1, float(v)))
|
||||
|
||||
def get_radius_boundvar(self):
|
||||
return radius
|
||||
|
||||
|
||||
###################
|
||||
# draw functions
|
||||
|
||||
@DrawCallbacks.on_draw('post3d')
|
||||
@FSM.onlyinstate({'main','stroking'})
|
||||
def draw_brush(self):
|
||||
if not self.hit: return
|
||||
|
||||
p, n = self.hit_p, self.hit_n
|
||||
ro = self.radius * self.hit_scale
|
||||
rh = self.inner_radius * self.hit_scale # ro * 0.5
|
||||
co, ci, cb = self.outer_color, self.inner_color, self.outer_border_color
|
||||
|
||||
gpustate.depth_mask(False)
|
||||
|
||||
fwd = Direction(self.rfcontext.Vec_forward()) * (self.hit_depth * 0.0005)
|
||||
|
||||
# draw below
|
||||
gpustate.depth_test('GREATER_EQUAL')
|
||||
Globals.drawing.draw3D_circle(p - fwd * 1.0, ro, cb * self.color_mult_below, n=n, width=8*self.hit_scale)
|
||||
Globals.drawing.draw3D_circle(p - fwd * 2.0, ro, co * self.color_mult_below, n=n, width=2*self.hit_scale)
|
||||
Globals.drawing.draw3D_circle(p - fwd * 2.0, rh, ci * self.color_mult_below, n=n, width=2*self.hit_scale)
|
||||
|
||||
# draw above
|
||||
gpustate.depth_test('LESS_EQUAL')
|
||||
Globals.drawing.draw3D_circle(p - fwd * 1.0, ro, cb, n=n, width=8*self.hit_scale)
|
||||
Globals.drawing.draw3D_circle(p - fwd * 2.0, ro, co, n=n, width=2*self.hit_scale)
|
||||
Globals.drawing.draw3D_circle(p - fwd * 2.0, rh, ci, n=n, width=2*self.hit_scale)
|
||||
|
||||
# reset
|
||||
gpustate.depth_test('LESS_EQUAL')
|
||||
gpustate.depth_mask(True)
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate('stroking')
|
||||
def draw_line(self):
|
||||
# draw brush strokes (screen space)
|
||||
#cr,cg,cb,ca = self.line_color
|
||||
gpustate.blend('ALPHA')
|
||||
Globals.drawing.draw2D_linestrip(self.stroke2D, themes['stroke'], width=2, stipple=[5, 5])
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate('brush sizing')
|
||||
def draw_brush_sizing(self):
|
||||
gpustate.blend('ALPHA')
|
||||
r = (self.sizing_pos - self.actions.mouse).length
|
||||
rh = self.inner_radius
|
||||
|
||||
# Globals.drawing.draw2D_circle(self.sizing_pos, r*0.75, self.fill_color, width=r*0.5)
|
||||
Globals.drawing.draw2D_circle(self.sizing_pos, r*1.0, self.outer_border_color, width=7)
|
||||
Globals.drawing.draw2D_circle(self.sizing_pos, r*1.0, self.outer_color, width=1)
|
||||
Globals.drawing.draw2D_circle(self.sizing_pos, rh, self.inner_color, width=1)
|
||||
|
||||
##################
|
||||
# mouse
|
||||
|
||||
def update_mouse(self):
|
||||
recompute = False
|
||||
recompute |= (self.last_mouse != self.actions.mouse)
|
||||
recompute |= (self.last_view != self.rfcontext.get_view_version())
|
||||
if not recompute: return
|
||||
self.last_mouse = self.actions.mouse
|
||||
self.last_view = self.rfcontext.get_view_version()
|
||||
|
||||
self.hit = False
|
||||
|
||||
p,n,_,_ = self.rfcontext.raycast_sources_mouse()
|
||||
if not p: return
|
||||
depth = self.rfcontext.Point_to_depth(p)
|
||||
if not depth: return
|
||||
scale = self.rfcontext.size2D_to_size(1.0, depth)
|
||||
if scale is None: return
|
||||
|
||||
self.hit = True
|
||||
self.hit_scale = scale
|
||||
self.hit_p = p
|
||||
self.hit_n = n
|
||||
self.hit_depth = depth
|
||||
|
||||
return RFWidget_BrushStroke
|
||||
@@ -0,0 +1,60 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import math
|
||||
import random
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
from ..rfwidget import RFWidget
|
||||
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.maths import Vec, Point, Point2D, Direction, Color
|
||||
from ...config.options import themes
|
||||
|
||||
|
||||
'''
|
||||
RFWidget_Default has no callbacks/actions.
|
||||
This RFWidget is useful for very simple cursor setting.
|
||||
'''
|
||||
|
||||
class RFWidget_Default_Factory:
|
||||
'''
|
||||
This is a class factory. It is needed, because the FSM is shared across instances.
|
||||
RFTools might need to share RFWidgets that are independent of each other.
|
||||
'''
|
||||
|
||||
@staticmethod
|
||||
def create(*, action_name=None, cursor='DEFAULT'):
|
||||
class RFWidget_Default(RFWidget):
|
||||
rfw_name = 'Default'
|
||||
rfw_cursor = cursor
|
||||
|
||||
@RFWidget.on_init
|
||||
def init(self):
|
||||
self.action_name = action_name
|
||||
|
||||
@FSM.on_state('main')
|
||||
def modal_main(self):
|
||||
pass
|
||||
|
||||
return RFWidget_Default
|
||||
@@ -0,0 +1,60 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import math
|
||||
import random
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
from ..rfwidget import RFWidget
|
||||
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.maths import Vec, Point, Point2D, Direction, Color
|
||||
from ...config.options import themes
|
||||
|
||||
|
||||
'''
|
||||
RFWidget_Default has no callbacks/actions.
|
||||
This RFWidget is useful for very simple cursor setting.
|
||||
'''
|
||||
|
||||
class RFWidget_Hidden_Factory:
|
||||
'''
|
||||
This is a class factory. It is needed, because the FSM is shared across instances.
|
||||
RFTools might need to share RFWidgets that are independent of each other.
|
||||
'''
|
||||
|
||||
@staticmethod
|
||||
def create(*, action_name=None, cursor='NONE'):
|
||||
class RFWidget_Hidden(RFWidget):
|
||||
rfw_name = 'Hidden'
|
||||
rfw_cursor = cursor
|
||||
|
||||
@RFWidget.on_init
|
||||
def init(self):
|
||||
self.action_name = action_name
|
||||
|
||||
@FSM.on_state('main')
|
||||
def modal_main(self):
|
||||
pass
|
||||
|
||||
return RFWidget_Hidden
|
||||
@@ -0,0 +1,106 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import math
|
||||
import random
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
from ..rfwidget import RFWidget
|
||||
|
||||
from ...addon_common.common import gpustate
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.drawing import DrawCallbacks
|
||||
from ...addon_common.common.maths import Vec, Point, Point2D, Direction, Color
|
||||
from ...config.options import themes
|
||||
|
||||
|
||||
'''
|
||||
RFWidget_LineCut handles a line cut in screen space.
|
||||
When cutting, a line segment is drawn from mouse down to current mouse position with a small circle at the very center
|
||||
'''
|
||||
|
||||
class RFWidget_LineCut_Factory:
|
||||
'''
|
||||
This function is a class factory. It is needed, because the FSM is shared across instances.
|
||||
RFTools might need to share RFWidgets that are independent of each other.
|
||||
'''
|
||||
|
||||
@staticmethod
|
||||
def create(action_name, line_color=None, circle_color=Color((1,1,1,0.5)), circle_border_color=Color((0,0,0,0.5))):
|
||||
class RFWidget_LineCut(RFWidget):
|
||||
rfw_name = 'Line'
|
||||
rfw_cursor = 'CROSSHAIR'
|
||||
|
||||
@RFWidget.on_init
|
||||
def init(self):
|
||||
self.action_name = action_name
|
||||
self.line2D = [None, None]
|
||||
self.line_color = line_color
|
||||
self.circle_color = circle_color
|
||||
self.circle_border_color = circle_border_color
|
||||
|
||||
@FSM.on_state('main')
|
||||
def modal_main(self):
|
||||
if self.actions.pressed('insert'):
|
||||
return 'line'
|
||||
|
||||
@FSM.on_state('line', 'enter')
|
||||
def modal_line_enter(self):
|
||||
self.line2D = [self.actions.mouse, None]
|
||||
tag_redraw_all('Line line_enter')
|
||||
|
||||
@FSM.on_state('line')
|
||||
def modal_line(self):
|
||||
if self.actions.released('insert'):
|
||||
self.callback_actions(self.action_name)
|
||||
return 'main'
|
||||
|
||||
if self.actions.pressed('cancel'):
|
||||
self.line2D = [None, None]
|
||||
self.actions.unuse('insert', ignoremods=True, ignoremulti=True)
|
||||
return 'main'
|
||||
|
||||
if self.line2D[1] != self.actions.mouse:
|
||||
self.line2D[1] = self.actions.mouse
|
||||
tag_redraw_all('Line line')
|
||||
self.callback_actioning(self.action_name)
|
||||
|
||||
@FSM.on_state('line', 'exit')
|
||||
def modal_line_exit(self):
|
||||
tag_redraw_all('Line line_exit')
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate('line')
|
||||
def draw_line(self):
|
||||
#cr,cg,cb,ca = self.line_color
|
||||
p0,p1 = self.line2D
|
||||
if not p0 or not p1: return
|
||||
ctr = p0 + (p1-p0)/2
|
||||
|
||||
gpustate.blend('ALPHA')
|
||||
Globals.drawing.draw2D_line(p0, p1, self.line_color or themes['stroke'], width=2, stipple=[2, 2]) # self.line_color)
|
||||
Globals.drawing.draw2D_circle(ctr, 10, self.circle_border_color, width=3) # dark rim
|
||||
Globals.drawing.draw2D_circle(ctr, 10, self.circle_color, width=1) # light center
|
||||
|
||||
return RFWidget_LineCut
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import math
|
||||
import random
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
from ..rfwidget import RFWidget
|
||||
|
||||
from ...addon_common.common import gpustate
|
||||
from ...addon_common.common.fsm import FSM
|
||||
from ...addon_common.common.globals import Globals
|
||||
from ...addon_common.common.blender import tag_redraw_all
|
||||
from ...addon_common.common.drawing import DrawCallbacks
|
||||
from ...addon_common.common.maths import Vec, Point, Point2D, Direction, Color
|
||||
from ...config.options import themes
|
||||
|
||||
|
||||
'''
|
||||
RFWidget_LineCut handles a line cut in screen space.
|
||||
When cutting, a line segment is drawn from mouse down to current mouse position with a small circle at the very center
|
||||
'''
|
||||
|
||||
class RFWidget_SelectBox_Factory:
|
||||
'''
|
||||
This function is a class factory. It is needed, because the FSM is shared across instances.
|
||||
RFTools might need to share RFWidgets that are independent of each other.
|
||||
'''
|
||||
|
||||
@staticmethod
|
||||
def create(action_name, *, set_color=None, add_color=None, del_color=None):
|
||||
class RFWidget_SelectBox(RFWidget):
|
||||
rfw_name = 'Select Box'
|
||||
rfw_cursor = 'CROSSHAIR'
|
||||
|
||||
@RFWidget.on_init
|
||||
def init(self):
|
||||
self.action_name = action_name
|
||||
self.box2D = [None, None]
|
||||
self.set_color = set_color or themes['set select']
|
||||
self.add_color = add_color or themes['add select']
|
||||
self.del_color = del_color or themes['del select']
|
||||
|
||||
@FSM.on_state('main')
|
||||
def modal_main(self):
|
||||
if self.actions.pressed({'select box'}, ignoremods=True):
|
||||
return 'box'
|
||||
|
||||
def quickselect_start(self):
|
||||
self._fsm.force_set_state('box')
|
||||
|
||||
@FSM.on_state('box', 'enter')
|
||||
def modal_line_enter(self):
|
||||
self.box2D = [self.actions.mouse, None]
|
||||
self.mods = None
|
||||
tag_redraw_all('Line line_enter')
|
||||
|
||||
@FSM.on_state('box')
|
||||
def modal_line(self):
|
||||
if self.actions.released('select box', ignoremods=True):
|
||||
self.callback_actions(self.action_name)
|
||||
return 'main'
|
||||
|
||||
if self.actions.pressed('cancel'):
|
||||
self.box2D = [None, None]
|
||||
self.actions.unuse('select box', ignoremods=True, ignoremulti=True)
|
||||
return 'main'
|
||||
|
||||
new_mods = {
|
||||
'ctrl': self.actions.ctrl,
|
||||
'alt': self.actions.alt,
|
||||
'shift': self.actions.shift,
|
||||
'oskey': self.actions.oskey,
|
||||
}
|
||||
if self.box2D[1] != self.actions.mouse or new_mods != self.mods:
|
||||
self.box2D[1] = self.actions.mouse
|
||||
self.mods = new_mods
|
||||
tag_redraw_all('boxing')
|
||||
self.callback_actioning(self.action_name)
|
||||
|
||||
@FSM.on_state('box', 'exit')
|
||||
def modal_line_exit(self):
|
||||
tag_redraw_all('Line line_exit')
|
||||
|
||||
@DrawCallbacks.on_draw('post2d')
|
||||
@FSM.onlyinstate('box')
|
||||
def draw_line(self):
|
||||
#cr,cg,cb,ca = self.line_color
|
||||
p0,p1 = self.box2D
|
||||
if not p0 or not p1: return
|
||||
|
||||
x0, y0 = p0
|
||||
x1, y1 = p1
|
||||
if self.mods['ctrl']: c = self.del_color
|
||||
elif self.mods['shift']: c = self.add_color
|
||||
else: c = self.set_color
|
||||
|
||||
gpustate.blend('ALPHA')
|
||||
Globals.drawing.draw2D_line((x0, y0), (x1, y0), c, width=1, stipple=[2, 2]) # self.line_color)
|
||||
Globals.drawing.draw2D_line((x1, y0), (x1, y1), c, width=1, stipple=[2, 2]) # self.line_color)
|
||||
Globals.drawing.draw2D_line((x1, y1), (x0, y1), c, width=1, stipple=[2, 2]) # self.line_color)
|
||||
Globals.drawing.draw2D_line((x0, y1), (x0, y0), c, width=1, stipple=[2, 2]) # self.line_color)
|
||||
|
||||
return RFWidget_SelectBox
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
'''
|
||||
Copyright (C) 2023 CG Cookie
|
||||
http://cgcookie.com
|
||||
hello@cgcookie.com
|
||||
|
||||
Created by Jonathan Denning, Jonathan Williamson, and Patrick Moore
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
'''
|
||||
|
||||
import os
|
||||
import bpy
|
||||
import glob
|
||||
import time
|
||||
|
||||
from .rf.rf_updatersystem import RetopoFlow_UpdaterSystem
|
||||
from .rf.rf_ui_alert import RetopoFlow_UI_Alert
|
||||
|
||||
from ..addon_common.common.fsm import FSM
|
||||
from ..addon_common.common.globals import Globals
|
||||
from ..addon_common.common import ui_core
|
||||
from ..addon_common.common.useractions import ActionHandler
|
||||
|
||||
from ..addon_common.cookiecutter.cookiecutter import CookieCutter
|
||||
|
||||
from ..config.keymaps import get_keymaps
|
||||
from ..config.options import options
|
||||
|
||||
|
||||
class RetopoFlow_OpenUpdaterSystem(CookieCutter, RetopoFlow_UpdaterSystem, RetopoFlow_UI_Alert):
|
||||
@classmethod
|
||||
def can_start(cls, context):
|
||||
return True
|
||||
|
||||
def blender_ui_set(self):
|
||||
self.viewaa_simplify()
|
||||
# self.manipulator_hide()
|
||||
self.panels_hide()
|
||||
# self.overlays_hide()
|
||||
self.quadview_hide()
|
||||
self.region_darken()
|
||||
self.header_text_set('RetopoFlow Updater System')
|
||||
|
||||
def start(self):
|
||||
ui_core.ASYNC_IMAGE_LOADING = options['async image loading']
|
||||
|
||||
# preload_help_images.paused = True
|
||||
keymaps = get_keymaps(force_reload=True)
|
||||
self.actions = ActionHandler(self.context, keymaps)
|
||||
self.reload_stylings()
|
||||
self.blender_ui_set()
|
||||
self.updater_open() #self.rf_startdoc, done_on_esc=True, closeable=True, on_close=self.done)
|
||||
Globals.ui_document.body.dirty(cause='changed document size', children=True)
|
||||
|
||||
def end(self):
|
||||
self._cc_blenderui_end()
|
||||
|
||||
# def update(self):
|
||||
# preload_help_images.paused = False
|
||||
|
||||
@FSM.on_state('main')
|
||||
def main(self):
|
||||
# print(f'Updater System main')
|
||||
if self.actions.pressed({'done', 'done alt0'}):
|
||||
self.done()
|
||||
return
|
||||
if self.actions.pressed({'F12'}):
|
||||
self.reload_stylings()
|
||||
return
|
||||
|
||||
|
||||
Reference in New Issue
Block a user