2025-07-01

This commit is contained in:
2026-03-17 14:30:01 -06:00
parent f9a22056dd
commit 62b5978595
4579 changed files with 1257472 additions and 0 deletions
@@ -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