diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b29fab4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,75 @@ +# Project ignore +.ipynb_checkpoints +.mypy_cache +.vscode +__pycache__ +.pytest_cache +htmlcov +dist +site +.coverage +coverage.xml +.netlify +test.db +log.txt +Pipfile.lock +env3.* +env +docs_build +venv +docs.zip +archive.zip + +# Ignore IntelliJ IDEA directories +.idea/ +*.iml +*.iws +*.ipr + +# Ignore PyCharm directories +__pycache__/ +*.pyc +*.pyo +*.pyd +*.pyc/ + +# Ignore VSCode directories +.vscode/ + +# Compiled source +*.com +*.class +*.dll +*.exe +*.o +*.so + +# Packages +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar +*.zip + +# Logs and databases +*.log +*.sql +*.sqlite + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +*.env +*.pypirc +*.egg-info +.ruff* +build diff --git a/README.md b/README.md index c693a2c..6f15523 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,47 @@ -# HPaint 1.2 -The Hpaint SOP is a viewport drawing utility for Houdini 18.5 & 19 (py2 and py3), for representing 'painted' strokes as UVed geometry. Hpaint masks your (tablet pressure sensitive) strokes to the input geometry, as well as conforming strokes to the geometry normal of underlying faces. +# Hbuild Logo HPaint 2.0 -**Download:** https://github.com/aaronsmithtv/hpaint/blob/main/otls/aaron_smith__hpaint__1_2.hdalc +![license](https://img.shields.io/badge/license-MIT-green) ![version](https://img.shields.io/badge/version-2.0-blue) -**NOTE:** I only own an Indie license, so this is currently only available as a .hdalc file. The HDA was last generated in Houdini 19.0.426 py3 +### 🎨 HPaint is a viewport drawing utility for Houdini 19.5, allowing you to digitally paint on any geometry. -![Hpaint Painting Example](https://github.com/aaronsmithtv/hpaint/blob/main/examples/images/hpaint_doc_gif_001.gif) +![Hpaint Painting Example](examples/images/hpaint_doc_gif_001.gif) -![Hpaint Annotation Example](https://github.com/aaronsmithtv/hpaint/blob/main/examples/images/hpaint_doc_gif_002.gif) +The concept is similar to Blender's Grease Pencil utility, with extra features allowing you to also perform 2D Animation tasks, draw in your viewport with Screen Drawing, control your drawing methods and enable geometry masking, as well as 'layer' your strokes with surface distance offsets and primitive groups. + +Each stroke is a 3D card with UVs - allowing you to texture your strokes similar to using a custom brush in Photoshop. + +### 🆕 New to HPaint 2.0 +- Updated for Houdini 19.5 +- Major optimizations for cache evaluation, drawing and data handling. +- Added `Colour Picker` utility, that samples the `Cd` attribute from the input geometry using `MMB`. +- Added `Disable Geometry Mask` toggle to allow strokes drawn off-geometry. + - Strokes are evaluated from the last normal-plane distance of the geometry before it went off-geometry. + - Strokes have an intelligent repositioning algorithm to allow strokes to look fluid no matter how they are drawn. +- Revamped `Screen Drawing` functionality. + - You can now screen draw in any present viewport. Previously you could only draw in camera view. + - You can choose between `Continuous` (Always screen draw at the `ScDraw Distance`) and `Once` (Draw from the first depth-picked position) modes. + - You can hold `Shift + MMB` to pick the `ScDraw Distance` depth from your present viewport. +- Added `Output Curves Only` toggle, which disables stroke card construction. +- Added extra visualization options in their own tab. + - Modern tool-tips have been added per 19.5 viewer state guidelines. + +## 🗝️ Key Features +- **🖌️ Digital Painting on your Geometry**: Plug HPaint into any SOP and start drawing! By default, HPaint allows you to sample and draw on geometry and mask your strokes to the geometry itself. +- **✍️ Screen Drawing**: HPaint offers versatile options for screen drawing, with the `Depth Picker` utility, depth sampling methods and an interactive screen distance parameter. +- **🎬 2D Animation**: Get started instantly with the `$F` frame tag in your filename and the `Display Nearest Frame` toggle to start animating like you would in softwares such as Toon Boom! +- **💽 Smart Disk Caching**: Save your HPaint cache out and share it with other HPaint users - enabling collaborative workflows. + +## 📥 Installation +[Download the HDA file](otls/aaronsmithtv__hpaint__2.0.hda) and install it to your `houdini19.5/otls/` folder. For detailed instructions, please refer to the [Houdini documentation](https://www.sidefx.com/docs/houdini/assets/install.html). + +## ⏩ Quick Start +After installing HPaint, open a SOP context node view and connect HPaint's single input to any geometry, preferably with the `N` normal attribute configured correctly. A test geometry works well for initial usage. + +With the display for the HPaint SOP enabled, move your cursor to the viewport and press `Enter`. This will display the tool-tips for HPaint, letting you know that the initialization has worked as intended. + +Begin creating strokes by holding `LMB` on your geometry - as you would in any other digital painting software! If you want to draw everywhere, toggle `Screen Drawing` or toggle `Disable Geometry Mask` to paint wherever you want on the viewport. + +## 💡 Feedback +If you have any feedback or run into issues, please feel free to open an issue on this GitHub project. I really appreciate your support! + +![Hpaint Annotation Example](examples/images/hpaint_doc_gif_003b.gif) diff --git a/examples/.gitkeep b/examples/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/examples/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/examples/geo/.gitkeep b/examples/geo/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/examples/geo/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/examples/hip/.gitkeep b/examples/hip/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/examples/hip/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/examples/images/.gitkeep b/examples/images/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/examples/images/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/hda_py/OnCreated.py b/hda_py/OnCreated.py new file mode 100644 index 0000000..c899539 --- /dev/null +++ b/hda_py/OnCreated.py @@ -0,0 +1,18 @@ +import hou + +node = kwargs["node"] + +# Set Colour +blue = hou.Color((0.6, 0.85, 0.98)) +node.setColor(blue) + +# Load initially pathed file +node.parm("hp_file_reload").pressButton() + +# # register python callback to be called on hip file event +# # e.g loading, saving: for reloading hpaint cache +# def hpaint_reload_se_callback(event_type): +# node.parm("hp_file_reload").pressButton() + +# with hou.undos.disabler(): +# hou.hipFile.addEventCallback(hpaint_reload_se_callback) diff --git a/hda_py/PythonModule.py b/hda_py/PythonModule.py new file mode 100644 index 0000000..21c3ce4 --- /dev/null +++ b/hda_py/PythonModule.py @@ -0,0 +1,631 @@ +import fnmatch +import glob +import os +import re +import typing + +import hou + + +def clear_geo_groups(geo: hou.Geometry) -> None: + # if geo is None: + # return + + for group in geo.primGroups(): + if group.primCount() < 1: + try: + group.destroy() + except hou.GeometryPermissionError: + continue + + +def clear_geo_attribs(geo: hou.Geometry) -> None: + # if geo is None: + # return + + pt_attribs = geo.pointAttribs() + prim_attribs = geo.primAttribs() + vtx_attribs = geo.vertexAttribs() + dt_attribs = geo.globalAttribs() + + for attrib in pt_attribs + prim_attribs + vtx_attribs + dt_attribs: + try: + attrib.destroy() + except hou.OperationFailed: + continue + + +def clear_strokecache(node: hou.Node): + """Delete the contents of the hpaint data parm""" + stroke_data_parm = node.parm(get_strokecache_name(node)) + + blank_geo = hou.Geometry() + clear_geo_groups(blank_geo) + clear_geo_attribs(blank_geo) + + stroke_data_parm.set(blank_geo) + node.parm("hp_file_reload").pressButton() + + +def clear_stroke_buffer(node: hou.Node): + """Callback associated with the 'clear stroke buffer' parm""" + + stroke_data_parm = node.parm(get_strokecache_name(node)) + + isogrp_toggle = node.parm(get_actiontoggle_name(node)).evalAsInt() + + isogrp_query = node.parm(get_actiongrp_name(node)).evalAsString() + + if isogrp_toggle: + new_geo = hou.Geometry() + clear_geo_groups(new_geo) + clear_geo_attribs(new_geo) + + stroke_geo = stroke_data_parm.evalAsGeometry() + + if stroke_geo: + new_geo.merge(stroke_geo) + + # use unix filematch syntax to find groups eligible for deletion + del_groups = find_multi_groups(new_geo, isogrp_query) + + new_geo = isolate_multigroups_v2(new_geo, del_groups) + + clear_geo_attribs(new_geo) + clear_geo_groups(new_geo) + + stroke_data_parm.set(new_geo) + + else: + blank_geo = hou.Geometry() + clear_geo_groups(blank_geo) + clear_geo_attribs(blank_geo) + stroke_data_parm.set(blank_geo) + return + + +def file_change_callback(node: hou.Node): + """ + When loading in a disk cache, overwrite the HDA's + stroke counter value with max_strokeid global (if + the global is higher than the counter) + """ + update_filecache(node) + global_name = "max_strokeid" + + maxiter_parm = node.parm("hp_stroke_num") + hda_maxiter_val = maxiter_parm.evalAsInt() + + diskcache_geo = get_filecache_geo(node) + + # overwrite the max strokes parm if disk global is higher value + diskcache_maxiter_val = -1 + + if diskcache_geo is not None: + if diskcache_geo.findGlobalAttrib(global_name) is not None: + diskcache_maxiter_val = diskcache_geo.attribValue(global_name) + + if diskcache_maxiter_val > hda_maxiter_val: + with hou.undos.disabler(): + maxiter_parm.set(diskcache_maxiter_val) + + +def save_cached_strokes(node: hou.Node): + """ + i/o operation for saving strokes currently in the stroke + buffer to disk + """ + + filepath_eval(node) + + stroke_data_parm = node.parm(get_strokecache_name(node)) + stroke_cache = stroke_data_parm.evalAsGeometry() + # check if stroke buffer has any geo + if stroke_cache: + geopath = node.parm(get_filepath_name(node)).evalAsString() + geopath = hou.text.normpath(geopath) + geopath = hou.text.abspath(geopath) + + filepath = geopath.rsplit("/", 1)[0] + # check file path and raise any windows error + + if not os.path.exists(filepath): + try: + os.makedirs(filepath) + except OSError: + if not os.path.isdir(filepath): + raise + # choose whether to merge into current file or save a new file + if os.path.exists(geopath): + # load the geometry from disk and merge into an editable geo + c_geo = hou.Geometry() + clear_geo_attribs(c_geo) + c_geo.loadFromFile(str(geopath)) + c_geo.merge(stroke_cache) + + # overwrite stroke cache on disk with a higher max_strokeid + # if the HDA's stroke counter is higher than disk cache global + maxiter_parm = node.parm("hp_stroke_num") + hda_maxiter_val = maxiter_parm.evalAsInt() + + sid_global_name = "max_strokeid" + + if c_geo is not None: + if c_geo.findGlobalAttrib(sid_global_name) is not None: + c_geo_maxiter_val = c_geo.attribValue(sid_global_name) + + if hda_maxiter_val > c_geo_maxiter_val: + c_geo.setGlobalAttribValue(sid_global_name, hda_maxiter_val) + + # need to add attrib if it does not exist? + try: + with hou.undos.disabler(): + c_geo.saveToFile(str(geopath)) + clear_strokecache(node) + # load new saved cached into disk cache data parm + update_filecache(node) + except hou.OperationFailed: + hou.ui.displayMessage("Hpaint: Failed to overwrite disk file") + else: + try: + with hou.undos.disabler(): + stroke_cache.saveToFile(str(geopath)) + clear_strokecache(node) + # load new saved cached into disk cache data parm + update_filecache(node) + except hou.OperationFailed: + hou.ui.displayMessage("Hpaint: Failed to save to disk") + + +def delete_filecache(node: hou.Node): + """ + Delete the contents of the pathed file on disk + """ + + confirm = hou.ui.displayConfirmation( + "Delete the stroke file cache on disk?", + suppress=hou.confirmType.OverwriteFile, + ) + + if confirm: + filepath_eval(node) + + geopath = node.parm(get_fpe_name(node)).evalAsString() + geopath = hou.text.normpath(geopath) + geopath = hou.text.abspath(geopath) + + if not os.path.exists(geopath): + return + + c_geo = hou.Geometry() + clear_geo_attribs(c_geo) + + try: + c_geo.loadFromFile(geopath) + load_success = True + except (hou.OperationFailed, hou.GeometryPermissionError): + load_success = False + + if load_success: + os.remove(geopath) + + node.parm("hp_file_reload").pressButton() + + +def clear_filecache(node: hou.Node): + """ + Delete the contents of the pathed file on disk + """ + + isogrp_toggle = node.parm(get_actiontoggle_name(node)).evalAsInt() + isogrp_query = node.parm(get_actiongrp_name(node)).evalAsString() + + if isogrp_toggle: + confirm = hou.ui.displayConfirmation( + "Clear strokes matching the group pattern {0} on disk?".format( + isogrp_query + ), + suppress=hou.confirmType.OverwriteFile, + ) + else: + confirm = hou.ui.displayConfirmation( + "Clear the stroke file cache on disk?", + suppress=hou.confirmType.OverwriteFile, + ) + + if confirm: + filepath_eval(node) + + geopath = node.parm(get_fpe_name(node)).evalAsString() + geopath = hou.text.normpath(geopath) + geopath = hou.text.abspath(geopath) + + isogrp_toggle = node.parm(get_actiontoggle_name(node)).evalAsInt() + isogrp_query = node.parm(get_actiongrp_name(node)).evalAsString() + + if os.path.exists(geopath): + # overwrite the disk file with a blank hou geo + + c_geo = hou.Geometry() + clear_geo_attribs(c_geo) + + if isogrp_toggle: + c_geo.loadFromFile(geopath) + del_groups = find_multi_groups(c_geo, isogrp_query) + c_geo = isolate_multigroups_v2(c_geo, del_groups) + + try: + c_geo.saveToFile(str(geopath)) + # load new saved cached into disk cache data parm + update_filecache(node) + except hou.OperationFailed: + hou.ui.displayMessage("Hpaint: Failed to overwrite disk file") + node.parm("hp_file_reload").pressButton() + + +def swap_file_into_buffer(node: hou.Node): + """ + Take the pathed disk geo and delete it, placing it into the stroke buffer. + This makes the disk file editable, and is essentially swapping between the uneditable 'file data parm' + to the 'buffer data parm' + """ + + isogrp_toggle = node.parm(get_actiontoggle_name(node)).evalAsInt() + isogrp_query = node.parm(get_actiongrp_name(node)).evalAsString() + + if isogrp_toggle: + confirm = hou.ui.displayConfirmation( + "Swap disk file group into stroke buffer?\nThis will remove any strokes in your current disk file (matching the group pattern {0}) and place them into the stroke buffer.".format( + isogrp_query + ), + suppress=hou.confirmType.OverwriteFile, + ) + else: + confirm = hou.ui.displayConfirmation( + "Swap disk file into stroke buffer?\nThis will remove any strokes in your current disk file and place them into the stroke buffer.", + suppress=hou.confirmType.OverwriteFile, + ) + + if confirm: + filepath_eval(node) + + diskcache_path = node.parm(get_fpe_name(node)).evalAsString() + diskcache_path = hou.text.normpath(diskcache_path) + diskcache_path = hou.text.abspath(diskcache_path) + + diskcache_geo = hou.Geometry() + clear_geo_attribs(diskcache_geo) + + if os.path.exists(diskcache_path): + try: + diskcache_geo.loadFromFile(diskcache_path) + except hou.OperationFailed: + return + # save a blank geo to the pathed disk file + # if it fails, return and do not update the buffer + resaved_geo = hou.Geometry() + clear_geo_attribs(resaved_geo) + if isogrp_toggle: + resaved_geo.merge(diskcache_geo) + del_groups = find_multi_groups(resaved_geo, isogrp_query) + resaved_geo = isolate_multigroups_v2(resaved_geo, del_groups) + try: + resaved_geo.saveToFile(str(diskcache_path)) + except hou.OperationFailed: + hou.ui.displayMessage("Hpaint: Failed to overwrite disk file") + return + # load new saved cached into disk cache data parm + with hou.undos.disabler(): + update_filecache(node) + + strokecache_parm = node.parm(get_strokecache_name(node)) + + strokecache_geo = strokecache_parm.evalAsGeometry() + + new_sc_geo = hou.Geometry() + clear_geo_attribs(new_sc_geo) + new_sc_geo.merge(strokecache_geo) + + if isogrp_toggle: + del_groups = find_multi_groups(diskcache_geo, isogrp_query) + isolate_dc_geo = isolate_multigroups_v2( + diskcache_geo, del_groups, inverse=True + ) + new_sc_geo.merge(isolate_dc_geo) + else: + new_sc_geo.merge(diskcache_geo) + + # set finally returned swap geo + with hou.undos.disabler(): + strokecache_parm.set(new_sc_geo) + + +def update_filecache(node: hou.Node): + """ + Push the disk file to the disk file read python SOP + """ + + filepath_eval(node) + + diskcache_geo = hou.Geometry() + clear_geo_attribs(diskcache_geo) + diskcache_path = node.parm(get_fpe_name(node)).evalAsString() + try: + diskcache_geo.loadFromFile(diskcache_path) + except hou.OperationFailed: + pass + + clear_geo_groups(diskcache_geo) + + with hou.undos.disabler(): + node.parm(get_filecache_name(node)).set(diskcache_geo) + + +def get_filecache_geo(node: hou.Node): + """ + Try to load the file cache on disk + """ + + filepath_eval(node) + + diskcache_geo = hou.Geometry() + clear_geo_attribs(diskcache_geo) + diskcache_path = node.parm(get_fpe_name(node)).evalAsString() + try: + diskcache_geo.loadFromFile(diskcache_path) + clear_geo_groups(diskcache_geo) + return diskcache_geo + except hou.OperationFailed: + return None + + +def set_global_attrib(input_geo: hou.Geometry, attrib_name: str, value, default_value): + """ + Set a global (detail) attrib + """ + if input_geo.findGlobalAttrib(attrib_name) is None: + input_geo.addAttrib(hou.attribType.Global, attrib_name, default_value) + input_geo.setGlobalAttribValue(attrib_name, value) + + +def find_multi_groups(geometry: hou.Geometry, query: str): + """Added for use with 'action by group' parm. Finds all groups + that match the input file pattern and returns primGroup tuple + + """ + groups_tuple = geometry.primGroups() + + group_names = [] + + for group in groups_tuple: + group_names.append(group.name()) + + reg_strokegroups = fnmatch.filter(group_names, query) + + query_delgroups = [] + + if reg_strokegroups is not None: + for strokegroup_name in reg_strokegroups: + del_grp = geometry.findPrimGroup(strokegroup_name) + if not del_grp: + continue + query_delgroups.append(del_grp) + + return query_delgroups + + +def isolate_multigroups(geometry, groups): + """Given input geometry and primGroup list, delete eligible prims.""" + cache_geometry = hou.Geometry() + clear_geo_attribs(cache_geometry) + cache_geometry.merge(geometry) + + for group in groups: + if cache_geometry is not None: + group_prims = group.prims() + cache_geometry.deletePrims(group_prims) + + return cache_geometry + + +def isolate_multigroups_v2( + geometry: hou.Geometry, + groups: typing.Union[tuple, hou.PrimGroup], + inverse: bool = False, +): + if not inverse: + group_globstring = " ".join(group.name() for group in groups) + else: + group_globstring = " !".join(group.name() for group in groups) + group_globstring = f"!{group_globstring}" + + cache_geometry = hou.Geometry() + clear_geo_attribs(cache_geometry) + cache_geometry.merge(geometry) + + glob_prims = cache_geometry.globPrims(group_globstring) + + cache_geometry.deletePrims(glob_prims) + + return cache_geometry + + +def isolate_multigroups_inverse(geometry, groups): + """Same as isolate_multigroups, but deletes any prims NOT in group list.""" + cache_geometry = hou.Geometry() + clear_geo_attribs(cache_geometry) + cache_geometry.merge(geometry) + + iso_prims = [] + + for group in groups: + if cache_geometry is not None: + group_prims = group.prims() + + for prim in group_prims: + if prim not in iso_prims: + iso_prims.append(prim) + + inverse_prims = [p for p in geometry.prims()] + + for iso_prim in iso_prims: + if iso_prim in inverse_prims: + inverse_prims.remove(iso_prim) + + cache_geometry.deletePrims(inverse_prims) + + return cache_geometry + + +def filepath_eval(node): + """Refresh the invisible file path with an absolute version, adhering to + the way that animation is evaluated + """ + + fpe_toggle = node.parm("hp_enable_llf").evalAsInt() + + geopath_parm = node.parm(get_filepath_name(node)) + evalparm = node.parm(get_fpe_name(node)) + + if geopath_parm.isTimeDependent() and fpe_toggle: + geopath_expr = geopath_parm.rawValue() + + fpe_type = node.parm("hp_near_method").evalAsInt() + + with hou.undos.disabler(): + geopath_abs = time_snap_expr(geopath_expr, fpe_type) + evalparm.set(geopath_abs) + + else: + geopath_abs = geopath_parm.evalAsString() + + with hou.undos.disabler(): + evalparm.set(geopath_abs) + + +def walk_time_expr(geopath_expr): + """Walk the length of the file path string to evaluate $F (accounting + for frame padding) + I couldn't think of a better way to do this... + Please let me know if there is!!! + """ + + padded_hsex = "$F" + + framex_cindex = -1 + + exrange_begin = -1 + exrange_end = -1 + + for i, v in enumerate(geopath_expr): + if v == "$" and len(geopath_expr) >= i + 1: + if geopath_expr[i + 1] == "F": + framex_cindex = i + 2 + + exrange_begin = i + break + + for i in range(framex_cindex, len(geopath_expr)): + eval_character = geopath_expr[i] + if eval_character.isdigit(): + padded_hsex += eval_character + else: + exrange_end = i + break + + return padded_hsex, exrange_begin, exrange_end + + +def time_snap_expr(geopath_expr, fpe_type): + """Given a raw file path expression in houdini (including $F) + snap to the condition given (0 = back 1 = forward) + + """ + geopath_abs = hou.expandString(geopath_expr) + + padded_hsex, exrange_begin, exrange_end = walk_time_expr(geopath_expr) + + geopath_query = hou.expandString(geopath_expr.replace(padded_hsex, "*")) + + path_candidates = glob.glob(geopath_query) + + if len(path_candidates) == 0: + return geopath_abs + else: + paths_corrected = [path.replace(os.sep, "/") for path in path_candidates] + + geopath_res = geopath_abs.replace(os.sep, "/") + + abspath_back = geopath_res + + if geopath_res not in paths_corrected: + paths_corrected.append(geopath_res) + + # natural sort to return human visibility accounting for $F + paths_corrected = natural_sort(paths_corrected) + + current_index = paths_corrected.index(geopath_res) + + if fpe_type == 0 and current_index - 1 >= 0: + abspath_back = paths_corrected[current_index - 1] + + elif fpe_type == 1 and current_index + 1 < len(paths_corrected): + abspath_back = paths_corrected[current_index + 1] + + return abspath_back + + +def set_ghost(node, condition): + """Toggle the ghosting of visualised non-save frames""" + ghost_switch_parm = node.node("ghost_switch").parm("input") + + with hou.undos.disabler(): + ghost_switch_parm.set(condition) + ghost_switch_parm.pressButton() + + return + + +def natural_sort(list_to_sort): + """ + Use natural sorting to ensure human (windows) list sort. + See: https://blog.codinghorror.com/sorting-for-humans-natural-sort-order/ + """ + + def convert(text): + return int(text) if text.isdigit() else text.lower() + + def alphanum_key(key): + return [convert(c) for c in re.split("([0-9]+)", key)] + + return sorted(list_to_sort, key=alphanum_key) + + +def get_strokecache_name(node): + """Get the name of the stroke cache parm""" + return "hp_strokecache" + + +def get_filecache_name(node): + """Get the name of the file cache dir/name""" + return "hp_filecache" + + +def get_filepath_name(node): + """Get the name of the file path dir/name""" + return "hp_file_path" + + +def get_fpe_name(node): + """Get the name of the buffered (callback evaluation) file path dir/name""" + return "hp_fpeval" + + +def get_actiontoggle_name(node): + """Get the name of the 'action by group' toggle parm name""" + return "hp_grp_iso" + + +def get_actiongrp_name(node): + """Get the name of the 'action by group' name parm name""" + return "hp_isogrp_name" diff --git a/hda_py/StateScript.py b/hda_py/StateScript.py new file mode 100644 index 0000000..c6acec8 --- /dev/null +++ b/hda_py/StateScript.py @@ -0,0 +1,1992 @@ +""" +State: Hpaint 2.0 +State type: aaron_smith::hpaint::2.0 +Description: Viewer state for Hpaint +Author: Aaron Smith +Date Created: August 26, 2021 - 11:32:36 +""" + +import logging +import typing +from typing import Any +import numpy as np + +import hou +import parmutils +import viewerstate.utils as vsu + +# logging.basicConfig(level=logging.INFO) + +""" +Thank you for downloading this HDA and spreading the joy of drawing in Houdini. +Hpaint was lovingly made in my free time, if you have any questions please email +me at aaron@aaronsmith.tv, or consider taking a look at the rest of my work at +https://aaronsmith.tv. + +For future updates: https://github.com/aaronsmithtv/hpaint +""" + +HDA_VERSION = 2.0 +HDA_AUTHOR = "aaronsmith.tv" + +GEO_INTERSECTION_TYPE = ( + 4 # The number of the parameter reference to intersection type 'Geometry' +) + +INPUT_GEO_NAME = "INPUT_GEO" +STROKE_READIN_NAME = "STROKE_READIN" + + +class StrokeParams(object): + """Stroke instance parameters. + + The class holds the stroke instance parameters as attributes for a given + stroke operator and instance number. + + Parameters can be accessed as follows: + + params = StrokeParams(node, 55) + params.colorr.set(red) + params.colorg.set(green) + etc... + + Attributes: + inst: int + The current stroke parm instance number for the internal stroke SOP. + Initialized at 1 for each stroke. + It is unlikely that this number will go above 1, as the stroke SOP is reset + every time a stroke is drawn and completed. + """ + + def __init__(self, node: hou.Node, inst: int): + self.inst = inst + # log_stroke_event(f"StrokeParams `inst` initialized: `{inst}`") + + param_name = "stroke" + str(inst) + prefix_len = len(param_name) + 1 + + def valid_parm(vparm): + return vparm.isMultiParmInstance() and vparm.name().startswith(param_name) + + params = filter(valid_parm, node.parms()) + for p in params: + self.__dict__[p.name()[prefix_len:]] = p + # log_stroke_event(self.__dict__) + + +class StrokeData(object): + """Holds the stroke data. + + Store the stroke's data within class to recall attributes that vary/change across a stroke + + Attributes that do not change across the length of a stroke are stored as metadata. + + Attributes: + pos: hou.Vector3 + dir: hou.Vector3 + proj_pos: hou.Vector3 + proj_uv: hou.Vector3 + proj_prim: int + hit: bool + pressure: float + time: float + tilt: float + angle: float + roll: float + """ + + VERSION = 2 + + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + @staticmethod + def create(): + return StrokeData( + pos=hou.Vector3(0.0, 0.0, 0.0), + dir=hou.Vector3(0.0, 0.0, 0.0), + proj_pos=hou.Vector3(0.0, 0.0, 0.0), + proj_uv=hou.Vector3(0.0, 0.0, 0.0), + proj_prim=-1, + hit=0, + pressure=1.0, + time=0.0, + tilt=0.0, + angle=0.0, + roll=0.0, + ) + + def reset(self): + self.pos = hou.Vector3(0.0, 0.0, 0.0) + self.dir = hou.Vector3(0.0, 0.0, 0.0) + self.hit = 0 + self.proj_pos = hou.Vector3(0.0, 0.0, 0.0) + self.proj_uv = hou.Vector3(0.0, 0.0, 0.0) + self.proj_prim = -1 + self.pressure = 1.0 + self.time = 0.0 + self.tilt = 0.0 + self.angle = 0.0 + self.roll = 0.0 + + def encode(self): + """Convert the data members to a hex string""" + stream = vsu.ByteStream() + stream.add(self.pos, hou.Vector3) + stream.add(self.dir, hou.Vector3) + stream.add(self.pressure, float) + stream.add(self.time, float) + stream.add(self.tilt, float) + stream.add(self.angle, float) + stream.add(self.roll, float) + stream.add(self.proj_pos, hou.Vector3) + stream.add(self.proj_prim, int) + stream.add(self.proj_uv, hou.Vector3) + stream.add(self.hit, int) + return stream + + def decode(self, stream): + pass + + +class StrokeMetaData(object): + """Holds the meta data from the stroke state client node. + + These are translated into primitive attributes by the Stroke SOP. + The default behaviour if this state is to copy any stroke_ prefixed + parameters into this meta data, but the build_stroke_metadata can + be overridden to add additional information. + """ + + def __init__(self): + self.name = None + self.size = 0 + self.type = None + self.value = None + + @staticmethod + def create(meta_data_array): + """Creates an array of StrokeMetaData from the client node parameters and converts it to a json string""" + import json + + # insert number of total elements + meta_data_array.insert(0, len(meta_data_array)) + + if len(meta_data_array) == 1: + meta_data_array.append({}) + + return json.dumps(meta_data_array) + + @staticmethod + def build_parms(node): + """Returns an array of stroke parameters to consider for meta data""" + + def filter_tparm(t): + """ + Filter out template parameters + """ + prefix = "stroke_" + builtins = ( + "stroke_numstrokes", + "stroke_radius", + "stroke_opacity", + "stroke_tool", + "stroke_color", + "stroke_projtype", + "stroke_projcenter", + "stroke_projgeoinput", + ) + # Take all parms which start with 'stroke_' but are not builtin + return t.name().startswith(prefix) and t.name() not in builtins + + g = node.parmTemplateGroup() + return filter(filter_tparm, parmutils.getAllParmTemplates(g)) + + +def project_point_dir( + node: hou.Node, + mouse_point: hou.Vector3, + mouse_dir: hou.Vector3, + intersect_geometry: hou.Geometry, + plane_center: hou.Vector3 = None, +) -> (hou.Vector3, hou.Vector3, hou.Vector3, int, bool): + """Performs a geometry intersection and returns a tuple with the intersection info. + + Returns: + point: intersection position + normal: intersected geometry's normal + uvw: parametric UV coordinates + prim_num: intersection primitive number + hit_success: return True if operation is successful or False otherwise + """ + proj_type = _eval_param(node, "stroke_projtype", 0) + prim_num = -1 + uvw = hou.Vector3(0.0, 0.0, 0.0) + + if proj_type == GEO_INTERSECTION_TYPE and intersect_geometry is not None: + hit_point_geo = hou.Vector3() + normal = hou.Vector3() + + prim_num = intersect_geometry.intersect( + mouse_point, mouse_dir, hit_point_geo, normal, uvw, None, 0, 1e18, 5e-3 + ) + if prim_num >= 0: + # log_stroke_event(f"Projected from point `{mouse_point}` in dir `{mouse_dir}` with `{intersect_geometry}`, returned data: Geo: `{hit_point_geo}`, Normal: `{normal}`, UVW: `{uvw}`, `{prim_num}`") + return hit_point_geo, normal, uvw, prim_num, True + + if plane_center is None: + plane_center = _eval_param_v3( + node, + "stroke_projcenterx", + "stroke_projcentery", + "stroke_projcenterz", + (0, 0, 0), + ) + plane_dir = mouse_dir + else: + plane_dir = mouse_dir * -1 + + try: + hit_point_plane = hou.hmath.intersectPlane( + plane_center, plane_dir, mouse_point, mouse_dir + ) + # log_stroke_event(f"Intersected position: `{hit_point_plane}`, from plane point: `{plane_center}`, plane normal: `{plane_dir}`, ray origin: `{mouse_point}`, ray dir: `{mouse_dir}`") + except Exception: + hit_point_plane = hou.Vector3() + + return hit_point_plane, None, uvw, prim_num, False + + +class StrokeCursorAdv(object): + """Implements the brush cursor used by the stroke state. + + Handles the creation of the advanced drawable, and provides methods + for various transform operations. + + Use self.drawable to edit drawable parameters such as the colour, and glow width. + """ + + def __init__(self, scene_viewer: hou.SceneViewer, state_name: hou.SceneViewer): + self.mouse_xform = hou.Matrix4() + + self.scene_viewer = scene_viewer + log_stroke_event(f"Scene Viewer assigned: `{self.scene_viewer}`") + self.state_name = state_name + log_stroke_event(f"State Name assigned: `{self.scene_viewer}`") + + # initialise the advanced drawable + self.drawable = self.init_brush() + + # return whether the cursor is intersecting with geometry for geo masking operations + self.is_hit = False + # return the hit primitive for eraser-based operations + self.hit_prim = -1 + + # initialise the location in geometry space + self.xform = hou.Matrix4(0.05) + + # initialise the mapping from 'geometry space' to 'model space' + self.model_xform = hou.Matrix4(1) + + self.last_cursor_pos = hou.Vector3() + self.last_normal = hou.Vector3() + self.last_uvw = hou.Vector3() + + # display prompt when entering the viewer state + self.prompt = "Left click to draw strokes. Ctrl+Left to erase strokes, Ctrl+Shift+Left to delete strokes. Shift drag to change stroke size." + + # control for whether drawing should be disabled (to allow resizing operation) + self.resizing = False + + def init_brush(self): + """Create the advanced drawable and return it to self.drawable""" + sops = hou.sopNodeTypeCategory() + verb = sops.nodeVerb("sphere") + + verb.setParms( + {"type": 2, "orient": 1, "rows": 13, "cols": 24}, + ) + cursor_geo = hou.Geometry() + verb.execute(cursor_geo, []) + + cursor_draw = hou.GeometryDrawableGroup("cursor") + + # adds the drawables + cursor_draw.addDrawable( + hou.GeometryDrawable( + self.scene_viewer, + hou.drawableGeometryType.Face, + "face", + params={ + "color1": (0.0, 1.0, 0.0, 1.0), + "color2": (0.0, 0.0, 0.0, 0.33), + "highlight_mode": hou.drawableHighlightMode.MatteOverGlow, + "glow_width": 2, + }, + ) + ) + cursor_draw.setGeometry(cursor_geo) + + return cursor_draw + + def set_color(self, color: hou.Vector4): + """Change the colour of the drawable whilst editing parameters in the viewer state""" + self.drawable.setParams({"color1": color}) + + def show(self): + """Enable the drawable""" + self.drawable.show(True) + + def hide(self): + """Disable the drawable""" + self.drawable.show(False) + + def update_position( + self, + node: hou.Node, + mouse_point: hou.Vector3, + mouse_dir: hou.Vector3, + rad: float, + intersect_geometry: hou.Geometry, + ) -> None: + """Overwrites the model transform with an intersection of cursor to geo. + also records if the intersection is hitting geo, and which prim is recorded in the hit + """ + (cursor_pos, normal, uvw, prim_num, hit) = project_point_dir( + node, mouse_point, mouse_dir, intersect_geometry + ) + + # update self.is_hit for geo masking + self.is_hit = hit + self.hit_prim = prim_num + + self.last_cursor_pos = cursor_pos + self.last_normal = normal + self.last_uvw = uvw + + # Position is at the intersection point oriented to go along the normal + srt = { + "translate": ( + self.last_cursor_pos[0], + self.last_cursor_pos[1], + self.last_cursor_pos[2], + ), + "scale": (rad, rad, rad), + "rotate": (0, 0, 0), + } + + # rotate_quaternion = hou.Quaternion() + # + # if hit and normal is not None: + # rotate_quaternion.setToVectors(hou.Vector3(0, 0, 1), normal) + # else: + # rotate_quaternion.setToVectors( + # hou.Vector3(0, 0, 1), hou.Vector3(mouse_dir).normalized() + # ) + # + # rotate = rotate_quaternion.extractEulerRotates() + # srt["rotate"] = rotate + + self.update_xform(srt) + + def update_xform(self, srt: dict) -> None: + """Overrides the current transform with the given dictionary. + The entries should match the keys of hou.Matrix4.explode. + """ + try: + current_srt = self.xform.explode() + current_srt.update(srt) + self.xform = hou.hmath.buildTransform(current_srt) + self.drawable.setTransform(self.xform * self.model_xform) + except hou.OperationFailed: + return + + def update_model_xform(self, viewport: hou.GeometryViewport) -> None: + """Update attribute model_xform by the selected viewport. + This will vary depending on our position type. + """ + + self.model_xform = viewport.modelToGeometryTransform().inverted() + self.mouse_xform = hou.Matrix4(1.0) + + def render(self, handle: int) -> None: + """Renders the cursor in the viewport with the onDraw python state + + optimise the onDraw method by reducing the amount of operations + calculated at draw time as possible + + Parameters: + handle: int + The current integer handle number + """ + + self.drawable.draw(handle) + + def show_prompt(self) -> None: + """Write the tool prompt used in the viewer state""" + self.scene_viewer.setPromptMessage(self.prompt) + + +def _eval_param(node: hou.Node, parm_path: str, default: Any) -> Any: + """Evaluates param on node, if it doesn't exist return default.""" + try: + return node.evalParm(parm_path) + except Exception: + return default + + +def _eval_param_v3( + node: hou.Node, param1: str, param2: str, param3: str, default: Any +) -> Any: + """Evaluates vector3 param on node, if it doesn't exist return default.""" + try: + return hou.Vector3( + node.evalParm(param1), node.evalParm(param2), node.evalParm(param3) + ) + except Exception: + return hou.Vector3(default) + + +def _eval_param_c( + node: hou.Node, param1: str, param2: str, param3: str, default: Any +) -> Any: + """Evaluates color param on node, if it doesn't exist return default.""" + try: + return hou.Color( + node.evalParm(param1), node.evalParm(param2), node.evalParm(param3) + ) + except Exception: + return hou.Color(default) + + +def get_node_stroke_colour(node: hou.Node) -> (float, float, float, float): + cursor_cr = node.parm("hp_colourr").eval() + cursor_cg = node.parm("hp_colourg").eval() + cursor_cb = node.parm("hp_colourb").eval() + cursor_ca = node.parm("hp_coloura").eval() + return cursor_ca, cursor_cb, cursor_cg, cursor_cr + + +class State(object): + """Stroke state implementation to handle the mouse/tablet interaction. + + Attributes: + state_name: str + The name of the HDA, for example, `aaron_smith::test::hpaint::1.2` + scene_viewer: hou.SceneViewer + The current scene viewer pane tab interacted with + """ + + RESIZE_ACCURATE_MODE = 0.2 + + HUD_TEMPLATE = { + "title": "HPaint", + "desc": f"{HDA_VERSION}", + "icon": "opdef:/aaron_smith::Sop/hpaint::1.3?IconSVG", + "rows": [ + {"id": "infodiv", "type": "divider", "label": "aaronsmith.tv"}, + {"id": "screendraw", "label": "Toggle Screen Draw", "key": "Shift D"}, + {"id": "input_guide", "label": "Toggle Input Guide", "key": "G"}, + {"id": "buttondiv", "type": "divider", "label": "Paint"}, + {"id": "paint_act", "label": "Paint", "key": "LMB"}, + {"id": "eraser_act", "label": "Erase", "key": "Ctrl LMB"}, + { + "id": "erasefullstroke_act", + "label": "Erase Entire Stroke", + "key": "Ctrl Shift LMB", + }, + { + "id": "radius_act", + "label": "Change Radius", + "key": "Shift LMB / mouse_wheel", + }, + {"id": "depthpicker_act", "label": "Colour Picker", "key": "MMB"}, + {"id": "depthpicker_act", "label": "Depth Picker", "key": "Shift MMB"}, + {"id": "surfacedist_act", "label": "Change Surface Offset", "key": "[ / ]"}, + {"id": "cachediv", "type": "divider", "label": "Cache"}, + { + "id": "screendraw", + "label": "Save Stroke Buffer To Disk", + "key": "Shift S", + }, + {"id": "screendraw", "label": "Clear Stroke Buffer", "key": "Shift C"}, + ], + } + + def __init__(self, state_name: str, scene_viewer: hou.SceneViewer): + self.__dict__.update(kwargs) + + # log_stroke_event(f"Initialized statename: `{state_name}`, scene viewer: `{scene_viewer}`") + + self.state_name = state_name + self.scene_viewer = scene_viewer + + self.scene_viewer.hudInfo(template=self.HUD_TEMPLATE) + + self.strokes: typing.Union[list, StrokeData] = [] + self.strokes_mirror_data = [] + self.strokes_next_to_encode = 0 + self.mouse_point = hou.Vector3() + self.mouse_dir = hou.Vector3() + self.stopwatch = vsu.Stopwatch() + self.epoch_time = 0 + + # meta_data_params is a cache for which parameters to copy + # to the metadata. + self.meta_data_parms = None + + self.enable_shift_drag_resize = True + + # capture_parms are any extra keys that should be passed from + # the kwargs of the event callbacks to the stroke-specific + # event callbacks. + self.capture_parms = [] + + # stores the geometry to intersect with. in hpaint, this is whatever is + # cooked at the null 'INPUT_GEO' (to swap between grid or input) + self.intersect_geometry = None + + # create the interactive cursor, which is represented in the viewport + # as a drawable. Storing it to allow changes to transform, colour and alpha + self.cursor_adv = StrokeCursorAdv(self.scene_viewer, self.state_name) + + self.undo_state = 0 + + # creates a secondary drawable, which is the title/author text displayed in viewport + self.text_drawable = hou.TextDrawable(self.scene_viewer, "text_drawable_name") + self.text_drawable.show(True) + + # decide if geo masking is used - this is permanently enabled + self.geo_mask = False + + # A toggle for evaluating an intersection per stroke evaluation point or an estimated position + # self.fast_eval = True + + # reorganised method for masking strokes to geometry for optimisation + # record the first hit to begin an undo state as well as beginning a + # stroke input that can be continued in the viewer state while LMB is held down + self.first_hit = True + + # used to decide whether the eraser should partially erase strokes or + # if it should delete an entire stroke instead. + self.eraser_fullstroke = False + + self.eraser_enabled = False + self.depthpicker_enabled = False + self.colourpicker_enabled = False + self.screendraw_enabled = False + + self.last_sd_dist = 2.5 + + self.last_mouse_x = 0 + self.last_mouse_y = 0 + + self.last_drawable_colour = hou.Vector4(0.05, 0.05, 0.05, 1.0) + + self.last_intersection_pos = None + + self.pressure_enabled = True + + self.radius_parm_name = "stroke_radius" + self.brushcolour_parm_name = "hp_colour" + + self.strokecache_parm_name = "hp_strokecache" + self.strokenum_parm_name = "hp_stroke_num" + + self.screendraw_parm_name = "hp_sd_enable" + self.screendrawdist_parm_name = "hp_sd_dist" + self.sddepthtype_parmname = "hp_sd_type" + + self.disablegeomask_parm_name = "disable_geo_mask" + self.curvesonly_parm_name = "output_curves" + # text draw generation + self.text_params = self.generate_text_drawable(self.scene_viewer) + + self.last_stroke_radius: float = 0.1 + self.last_stroke_opacity: float = 1.0 + self.last_stroke_tool: int = 1 + self.last_stroke_color: hou.Color = hou.Color() + self.last_stroke_projtype: int = 1 + self.last_stroke_projcenter: hou.Vector3 = hou.Vector3() + + self.last_meta_data_array = [] + + self.last_sd_depth_type = 1 + self.last_sd_pt = hou.Vector3() + self.last_sd_dir = hou.Vector3(0.0, 0.0, -1.0) + self.last_sd_hp = hou.Vector3() + + self.last_mw = 0.0 + + def onPreStroke(self, node: hou.Node, ui_event: hou.UIEvent) -> None: + """Called when a stroke is started. + Override this to setup any stroke_ parameters. + """ + + vsu.triggerParmCallback("prestroke", node, ui_event.device()) + + def onPostStroke(self, node: hou.Node, ui_event: hou.UIEvent) -> None: + """Called when a stroke is complete + Appended to any end block in stroke_interactive and masked equivalent + """ + + # +1 to the HDA's internal stroke counter. used to generate + # unique stroke IDs that the eraser can read + self.add_stroke_num(node) + + # now that a stroke is completed, store it in the 'stroke buffer' + # which is a data parm that contains all currently drawn geometry + # this is for speeding the viewer state up while drawing + self.cache_strokes(node) + + # revert the stroke SOP to an initialised state to begin a fresh stroke + self.reset_stroke_parms(node) + + vsu.triggerParmCallback("poststroke", node, ui_event.device()) + + def onPreApplyStroke(self, node: hou.Node, ui_event: hou.UIEvent) -> None: + """Called before new stroke values are copied. + This is done during the stroke operation. + + Override this to do any preparation just before the stroke + parameters are updated for an active stroke. + """ + pass + + def onPostApplyStroke(self, node: hou.Node, ui_event: hou.UIEvent) -> None: + """Called before after new stroke values are copied. This is done + during the stroke operation. + + Override this to do any clean up for every stroke + update. This can be used to break up a single stroke + into a series of operations, for example. + """ + pass + + def onPreMouseEvent(self, node: hou.Node, ui_event: hou.UIEvent) -> None: + """Called at the start of every mouse event. + + This is outside of undo blocks, so do not + set parameters without handling undos. + + Override this to inject code just before all mouse event + processing + """ + + def onPostMouseEvent(self, node: hou.Node, ui_event: hou.UIEvent) -> None: + """Called at the end of every mouse event. + + This is outside of undo blocks, so do not + set parameters without handling undos. + + Override this to inject code just after all mouse event + processing + """ + pass + + def build_stroke_metadata(self, node: hou.Node) -> list: + """Returns an array of dictionaries storing the metadata for the stroke. + + This is encoded as JSON and put in the stroke metadata parameter. + + Base behaviour is to encode all stroke_ prefixed parms. + + mirrorxform is the current mirroring transform being + written out. + + Override this to add metadata without the need to + make stroke_ parameters. + """ + convertible_to_int = ( + hou.parmTemplateType.Toggle, + hou.parmTemplateType.Menu, + ) + + meta_data_array = [] + for p in self.meta_data_parms: + name = p.name() + data_type = p.type() + + meta_data = StrokeMetaData() + meta_data.size = 1 + meta_data.name = name + + if data_type == hou.parmTemplateType.Float: + values = node.evalParmTuple(name) + meta_data.type = "float" + meta_data.size = len(values) + meta_data.value = " ".join(map(str, values)) + elif data_type == hou.parmTemplateType.Int: + values = node.evalParmTuple(name) + meta_data.type = "int" + meta_data.size = len(values) + meta_data.value = " ".join(map(str, values)) + elif data_type == hou.parmTemplateType.String: + meta_data.type = "string" + meta_data.value = node.evalParm(name) + elif data_type in convertible_to_int: + meta_data.type = "int" + meta_data.value = str(node.evalParm(name)) + else: + continue + + meta_data_array.append(meta_data.__dict__) + return meta_data_array + + def onEnter(self, kwargs: dict) -> None: + """Called whenever the state begins. + + Override this to perform any setup, such as visualizers, + that should be active whenever the state is. + + Parameters: + kwargs: dict + The keyword arguments for the viewer state beginning. These kwargs + are generated by houdini in the following format: + 'state_name', 'state_parms', 'state_flags' ('mouse_drag', 'redraw'), 'node' + """ + + # log_stroke_event(f"onEnter kwargs: `{kwargs}`") + + node = kwargs["node"] + + # replaced STROKECURSOR.size with float value + # initialise the cursor radius + rad = _eval_param(node, self.get_radius_parm_name(), 0.05) + self.cursor_adv.update_xform({"scale": (rad, rad, rad)}) + # hide the cursor before it has inherited a screen transform + self.cursor_adv.hide() + + # pre-build a list of meta data parameters from the node + self.meta_data_parms = StrokeMetaData.build_parms(node) + + # display the viewer state prompt + self.cursor_adv.show_prompt() + + self.scene_viewer.hudInfo(values={}) + + def onExit(self, kwargs: dict) -> None: + """Called whenever the state ends. + + Override this to perform any cleanup, such as visualizers, + that should be finished whenever the state is. + """ + vsu.Menu.clear() + + def onMouseEvent(self, kwargs: dict) -> None: + """Process mouse events, such as a left or right mouse button press + + Button press events can be evaluated using the `ui_event` kwarg, in a + method such as `ui_event.device().isLeftButton()` + + Parameters: + kwargs: dictViewerEvent + The keyword arguments for the mouse moving during viewer state event. + These kwargs are generated by houdini in the following format: + 'state_name', 'state_parms', 'state_flags' ('mouse_drag', 'redraw'), + 'ui_event' (of class ViewerEvent. This contains information on the device (keys) pressed, pressure, tilt etc.), + 'node' + """ + + # log_stroke_event(f"Kwargs for onMouseEvent: `{kwargs}`") + + ui_event: hou.ViewerEvent = kwargs["ui_event"] + node: hou.Node = kwargs["node"] + + self.transform_cursor_position(node, ui_event) + + # display the cursor after xform applied + self.cursor_adv.show() + + # Ignore commands if mousewheel is currently moving + # if self.eval_mousewheel_movement(ui_event): + # return + + # SHIFT DRAG RESIZING + started_resizing = False + started_resizing = self.shift_key_resize_event(started_resizing, ui_event) + + if self.cursor_adv.resizing: + self.resize_by_ui_event(node, started_resizing, ui_event) + return + + # update the state of eraser usage + self.update_brush_type(ui_event) + + self.apply_drawable_brush_colour(node) + + self.handle_stroke_event(ui_event, node) + + # Geometry masking system + # If the cursor moves off of the geometry during a stroke draw - a new stroke is created. + # New strokes cannot be created off draw + if self.eraser_enabled: + self.eraser_interactive_v2(ui_event, node) + return + elif self.depthpicker_enabled: + self.depthpicker_interactive(ui_event, node) + return + elif self.colourpicker_enabled: + self.colourpicker_interactive(ui_event, node) + else: + self.eval_mask_state(node) + if self.geo_mask and not self.screendraw_enabled: + self.stroke_interactive_mask(ui_event, node) + return + # If geometry masking is disabled, hits are not accounted for + # Using a simplified version of the sidefx_stroke.py method + else: + self.stroke_interactive(ui_event, node) + return + + def get_ui_centre(self, ui_event: hou.UIEvent) -> (hou.Vector3, hou.Vector3): + viewport = ui_event.curViewport() + vp_x, vp_y, vp_width, vp_height = viewport.size() + vp_dir, vp_origin = viewport.mapToWorld(vp_width / 2, vp_height / 2) + return vp_origin, vp_dir.normalized() + + def update_screendraw_eval(self, node: hou.Node, ui_event: hou.UIEvent) -> None: + self.screendraw_enabled = _eval_param(node, self.screendraw_parm_name, 0) + + if self.screendraw_enabled: + self.last_sd_pt, self.last_sd_dir = self.get_ui_centre(ui_event) + + self.last_sd_depth_type = _eval_param(node, self.sddepthtype_parmname, 1) + if self.last_sd_depth_type == 0: + input_geo = self.get_input_geo(node) + + if not input_geo: + return + + dist = self.get_distance_to_ppoint(input_geo, node) + + self.last_sd_dist = dist + elif self.last_sd_depth_type == 1: + self.last_sd_dist = _eval_param(node, self.screendrawdist_parm_name, 0) + + def eval_mousewheel_movement(self, ui_event: hou.UIEvent) -> bool: + mw = ui_event.device().mouseWheel() + + self.last_mw = mw + + if 1.0 >= mw >= -1.0 and mw != 0.0: + return True + return False + + def eval_mask_state(self, node: hou.Node): + mask_state_parm = node.parm("disable_geo_mask") + mask_state = mask_state_parm.evalAsInt() + if mask_state == 1: + if self.geo_mask: + self.geo_mask = False + else: + if not self.geo_mask: + self.geo_mask = True + + def apply_drawable_brush_colour(self, node: hou.Node): + if self.eraser_enabled: + # set eraser colour + self.cursor_adv.set_color(hou.Vector4(1.0, 0.0, 0.0, 1.0)) + elif self.depthpicker_enabled: + self.cursor_adv.set_color(hou.Vector4(0.4, 0.6, 1.0, 1.0)) + else: + cursor_ca, cursor_cb, cursor_cg, cursor_cr = get_node_stroke_colour(node) + + cursor_color = hou.Vector4(cursor_cr, cursor_cg, cursor_cb, cursor_ca) + + self.cursor_adv.set_color(cursor_color) + + def resize_by_ui_event( + self, node: hou.Node, started_resizing: bool, ui_event: hou.ViewerEvent + ) -> None: + """Given a UI event and condition for resizing, resize the cursor with the current parameter size.""" + mouse_x = ui_event.device().mouseX() + mouse_y = ui_event.device().mouseY() + # using the cached mouse pos, add the current mouse pos + # to the old pos to get a distance (used as new radius multiplier) + dist = -self.last_mouse_x + mouse_x + dist += -self.last_mouse_y + mouse_y + self.last_mouse_x = mouse_x + self.last_mouse_y = mouse_y + if started_resizing: + # opens an undo block for the brush operation + self.undoblock_open("Brush Resize") + pass + self.resize_cursor(node, dist) + if ui_event.reason() == hou.uiEventReason.Changed: + # closes the current brush undo block + self.cursor_adv.resizing = False + self.undoblock_close() + + def shift_key_resize_event( + self, started_resizing: bool, ui_event: hou.ViewerEvent + ) -> bool: + """Enables static shift-key resizing (similar to photoshop)""" + # check shift (resize key) is not conflicting with eraser keys + if ( + ui_event.reason() == hou.uiEventReason.Start + and ui_event.device().isShiftKey() + and not ui_event.device().isCtrlKey() + and not ui_event.device().isMiddleButton() + ): + # if stroke has begun, enable resizing and cache mouse position + self.cursor_adv.resizing = True + started_resizing = True + self.last_mouse_x = ui_event.device().mouseX() + self.last_mouse_y = ui_event.device().mouseY() + return started_resizing + + def transform_cursor_position( + self, node: hou.Node, ui_event: hou.ViewerEvent + ) -> None: + """Transforms the cursor position to the new rayed viewer event position + + THis uses the position of the mouse point and relative direction towards + the 3D scene to figure out where on the geometry the cursor should be displayed. + """ + # record a mouse position + direction from the ui_event + (self.mouse_point, self.mouse_dir) = ui_event.ray() + + # logic for applying tablet pressure to cursor radius, and + # updating the cursor transform in 3d space + # check if there are no device events in the queue + if not ui_event.hasQueuedEvents() and not self.cursor_adv.resizing: + # evaluate the radius parameter for a 'default' radius value + radius_parmval = _eval_param(node, self.get_radius_parm_name(), 0.1) + if ui_event.device().isLeftButton() and len(self.strokes) > 0: + if self.is_pressure_enabled() and not self.first_hit: + # if a stroke currently exists, update the default radius value + # with a multiplication of the current tablet pressure + pressure_rad = self.strokes[-1].pressure + if pressure_rad > 1.0: + pressure_rad = 1.0 + if pressure_rad > 1e-8: + radius_parmval *= pressure_rad + else: + radius_parmval *= 1e-8 + + self.cursor_adv.update_model_xform(ui_event.curViewport()) + self.cursor_adv.update_position( + node, + mouse_point=self.mouse_point, + mouse_dir=self.mouse_dir, + rad=radius_parmval, + intersect_geometry=self.get_intersection_geometry(node), + ) + + def onMouseWheelEvent(self, kwargs: dict) -> None: + """Called whenever the mouse wheel moves. + + Default behaviour is to resize the cursor. + + Override this to do different things on mouse wheel. + + This contains the standard onMouseWheelEvent kwargs specified in the + Houdini viewer state documentation. + """ + ui_event = kwargs["ui_event"] + node = kwargs["node"] + + dist = ui_event.device().mouseWheel() + dist *= 10.0 + + # Slow resizing enabled on shift key + if ui_event.device().isShiftKey() is True: + dist *= State.RESIZE_ACCURATE_MODE + + # middle mouse event refreshes the parm enough times to create + # unnecessary undo spam - this disables resize_cursor undos + with hou.undos.disabler(): + self.resize_cursor(node, dist) + + def onResume(self, kwargs: dict) -> None: + """Called whenever the state is resumed from an interruption. + + This contains the standard onResume kwargs specified in the + Houdini viewer state documentation. + """ + self.cursor_adv.show() + self.cursor_adv.show_prompt() + + self.log("cursor = ", self.cursor_adv) + + def onInterrupt(self, kwargs: dict) -> None: + """Called whenever the state is temporarily interrupted. + + This contains the standard onInterrupt kwargs specified in the + Houdini viewer state documentation. + """ + self.cursor_adv.hide() + + def onMenuAction(self, kwargs: dict) -> None: + """Called when a state menu is selected. + + This contains the standard onMenuAction kwargs specified in the + Houdini viewer state documentation. + """ + menu_item = kwargs["menu_item"] + node = kwargs["node"] + + if menu_item == "press_save_to_file": + node.parm("hp_save_file").pressButton() + + elif menu_item == "press_clear_buffer": + node.parm("hp_clear_buffer").pressButton() + + elif menu_item == "toggle_guide_vis": + guide_vis_parm = node.parm("hp_hide_geo") + guide_vis_tog = guide_vis_parm.evalAsInt() + if guide_vis_tog: + guide_vis_parm.set(0) + else: + guide_vis_parm.set(1) + + elif menu_item == "toggle_screen_draw": + screen_draw_parm = node.parm("hp_sd_enable") + screen_draw_tog = screen_draw_parm.evalAsInt() + if screen_draw_tog: + screen_draw_parm.set(0) + else: + screen_draw_parm.set(1) + + elif menu_item == "stroke_sdshift_down": + self.shift_surface_dist(node, 0) + + elif menu_item == "stroke_sdshift_up": + self.shift_surface_dist(node, 1) + + elif menu_item == "action_by_group": + actiongroup_parm = node.parm("hp_grp_iso") + actiongroup_tog = actiongroup_parm.evalAsInt() + if actiongroup_tog: + actiongroup_parm.set(0) + else: + actiongroup_parm.set(1) + + def onDraw(self, kwargs: dict) -> None: + """Called every time the viewport renders. + + This contains the standard onDraw kwargs specified in the + Houdini viewer state documentation. + """ + + # draw the text in the viewport upper left + handle = kwargs["draw_handle"] + + # self.text_drawable.draw(handle, self.text_params) + + # draw the cursor + self.cursor_adv.render(handle) + + def get_radius_parm_name(self) -> str: + """Returns the parameter name for determining the current radius of the brush.""" + return self.radius_parm_name + + def get_strokecache_parm_name(self) -> str: + """Returns the name of the hpaint strokecache""" + return self.strokecache_parm_name + + def get_strokenum_parm_name(self) -> str: + """Returns the name of the hpaint strokecache""" + return self.strokenum_parm_name + + def get_intersection_geometry(self, node: hou.Node) -> hou.Geometry: + """Returns the geometry to use for intersections of the ray.""" + proj_type = _eval_param(node, "stroke_projtype", 0) + + if proj_type == GEO_INTERSECTION_TYPE: + if len(node.inputs()) and node.inputs()[0] is not None: + # check if intersect is being used as eraser or pen + if not self.eraser_enabled: + isectnode = node.node(INPUT_GEO_NAME) + else: + isectnode = node.node(STROKE_READIN_NAME) + if self.intersect_geometry is None: + self.intersect_geometry = isectnode.geometry() + else: + # Check to see if we have already cached this. + if self.intersect_geometry.sopNode() != isectnode: + self.intersect_geometry = isectnode.geometry() + else: + self.intersect_geometry = None + return self.intersect_geometry + + def get_input_geo(self, node: hou.Node) -> hou.Geometry: + """Returns the geometry to use for intersections of the ray.""" + + if len(node.inputs()) and node.inputs()[0] is not None: + return node.inputs()[0].geometry() + else: + return None + + def active_mirror_transforms(self) -> hou.Matrix4: + """Returns a list of active transforms to mirror the incoming strokes with. + + The first should be identity to represent passing through. + If an empty list, no strokes will be recorded. + + Override this to add mirror transforms. + """ + result = hou.Matrix4() + result.setToIdentity() + return [result] + + def handle_stroke_end(self, node: hou.Node, ui_event: hou.ViewerEvent) -> None: + """Handles the end of a stroke""" + self.reset_active_stroke() + self.first_hit = True + self.onPostStroke(node, ui_event) + self.undoblock_close() + + def stroke_interactive_mask( + self, ui_event: hou.ViewerEvent, node: hou.Node + ) -> None: + """The logic for drawing a stroke, opening/closing undo blocks, and assigning prestroke / poststroke callbacks. + + The 'mask' variation of stroke_interactive uses the + 'is hit' attribute of the drawable cursor to close + strokes that are drawn off the edge of the mask geo. + """ + + is_active_or_start = ui_event.reason() in ( + hou.uiEventReason.Active, + hou.uiEventReason.Start, + ) + is_changed = ui_event.reason() == hou.uiEventReason.Changed + is_cursor_hit = self.cursor_adv.is_hit + + if self.first_hit: + self.update_screendraw_eval(node, ui_event) + self.get_stroke_defaults(node) + self.last_meta_data_array = self.build_stroke_metadata(node) + + if is_active_or_start and self.first_hit and is_cursor_hit: + self.undoblock_open("Draw Stroke") + self.reset_active_stroke() + self.onPreStroke(node, ui_event) + self.apply_stroke(node, False) + self.first_hit = False + elif is_active_or_start and not self.first_hit: + if is_cursor_hit: + self.apply_stroke(node, True) + else: + self.handle_stroke_end(node, ui_event) + elif is_changed and not self.first_hit: + self.handle_stroke_end(node, ui_event) + # elif not is_changed and is_active_or_start: + # self.handle_stroke_end(node, ui_event) + + def stroke_interactive(self, ui_event: hou.ViewerEvent, node: hou.Node) -> None: + """The logic for drawing a stroke, opening/closing undo blocks, and assigning prestroke / poststroke callbacks.""" + + is_active_or_start = ui_event.reason() in ( + hou.uiEventReason.Active, + hou.uiEventReason.Start, + ) + is_changed = ui_event.reason() == hou.uiEventReason.Changed + + if self.first_hit: + self.update_screendraw_eval(node, ui_event) + self.get_stroke_defaults(node) + self.last_meta_data_array = self.build_stroke_metadata(node) + + if self.screendraw_enabled: + # self.last_sd_pt, self.last_sd_dir = self.get_ui_centre(ui_event) + + self.last_sd_hp = hou.hmath.intersectPlane( + self.last_sd_pt + (self.last_sd_dir.normalized() * self.last_sd_dist), + self.last_sd_dir, + self.mouse_point, + self.mouse_dir, + ) + + if is_active_or_start and self.first_hit: + self.undoblock_open("Draw Stroke") + self.reset_active_stroke() + self.onPreStroke(node, ui_event) + self.apply_stroke(node, False) + self.first_hit = False + elif is_active_or_start and not self.first_hit: + self.apply_stroke(node, True) + elif is_changed and not self.first_hit: + self.handle_stroke_end(node, ui_event) + elif not is_changed and is_active_or_start: + self.handle_stroke_end(node, ui_event) + + def colourpicker_interactive( + self, ui_event: hou.ViewerEvent, node: hou.Node + ) -> None: + if self.cursor_adv.is_hit and self.cursor_adv.hit_prim >= 0: + input_geo = self.get_input_geo(node) + + if not input_geo: + return + + prim = input_geo.prim(self.cursor_adv.hit_prim) + try: + colour = prim.attribValueAtInterior( + "Cd", + self.cursor_adv.last_uvw.x(), + self.cursor_adv.last_uvw.y(), + self.cursor_adv.last_uvw.z(), + ) + + self.set_brush_colour(hou.Vector3(colour), node) + except hou.OperationFailed as e: + log_stroke_event(e) + + def depthpicker_interactive( + self, ui_event: hou.ViewerEvent, node: hou.Node + ) -> None: + if ( + ui_event.reason() == hou.uiEventReason.Active + or ui_event.reason() == hou.uiEventReason.Start + ): + if self.first_hit is True: + self.undoblock_open("Depth Picker") + self.first_hit = False + + # self.set_screendraw_enabled(0, node) + + if self.cursor_adv.is_hit and self.cursor_adv.hit_prim >= 0: + input_geo = self.get_input_geo(node) + + if not input_geo: + return + + dist = self.get_distance_to_ppoint(input_geo, node) + + self.set_screendraw_dist(dist, node) + + elif ui_event.reason() == hou.uiEventReason.Changed: + self.undoblock_close() + + self.first_hit = True + + def get_distance_to_ppoint(self, input_geo: hou.Geometry, node: hou.Node): + """Get the total distance to the projected intersection point""" + mouse_pos = self.strokes[-1].pos + (proj_pos, _, proj_uv, proj_prim, hit) = project_point_dir( + node=node, + mouse_point=mouse_pos, + mouse_dir=self.mouse_dir, + intersect_geometry=input_geo, + ) + dist = (mouse_pos - proj_pos).length() + return dist + + def set_screendraw_enabled(self, enabled: int, node: hou.Node): + try: + node.parm(self.screendraw_parm_name).set(enabled) + + except hou.OperationFailed as e: + log_stroke_event(e) + + def set_screendraw_dist(self, dist: float, node: hou.Node): + try: + node.parm(self.screendrawdist_parm_name).set(dist) + + except hou.OperationFailed as e: + log_stroke_event(e) + + def set_brush_colour(self, colour: hou.Vector3, node: hou.Node): + try: + node.parm(self.brushcolour_parm_name + "r").set(colour.x()) + node.parm(self.brushcolour_parm_name + "g").set(colour.y()) + node.parm(self.brushcolour_parm_name + "b").set(colour.z()) + + except hou.OperationFailed as e: + log_stroke_event(e) + + def eraser_interactive(self, ui_event: hou.ViewerEvent, node: hou.Node) -> None: + """The logic for erasing as a stroke, and opening an eraser-specific undo block.""" + if ( + ui_event.reason() == hou.uiEventReason.Active + or ui_event.reason() == hou.uiEventReason.Start + ): + if self.first_hit is True: + self.undoblock_open("Eraser") + self.first_hit = False + + if self.cursor_adv.is_hit and self.cursor_adv.hit_prim >= 0: + intersect_geometry = self.get_intersection_geometry(node) + + # get the intersecting prim from the cursor, to delete prim seg + geo_prim = intersect_geometry.prim(self.cursor_adv.hit_prim) + + if geo_prim is not None: + seg_id = geo_prim.attribValue("seg_id") + stroke_id = geo_prim.attribValue("stroke_id") + + # segment + stroke groups are assigned to strokes during their SOP + # generation process in-HDA. This process finds the geometry + # associated with the group name and replaces the stroke buffer + # with a version without the associated geo. + if self.eraser_fullstroke is False: + seg_group_name = "__hstroke_{0}_{1}".format(stroke_id, seg_id) + else: + seg_group_name = "__hstroke_{0}".format(stroke_id) + + # load in the stroke buffer geometry + stroke_data_parm = node.parm(self.get_strokecache_parm_name()) + cache_geo = stroke_data_parm.evalAsGeometry() + + seg_group = cache_geo.findPrimGroup(seg_group_name) + + if seg_group is not None: + seg_group_prims = seg_group.prims() + + new_geo = hou.Geometry() + + new_geo.merge(cache_geo) + + new_geo.deletePrims(seg_group_prims) + + if new_geo: + stroke_data_parm.set(new_geo) + + elif ui_event.reason() == hou.uiEventReason.Changed: + self.undoblock_close() + + self.first_hit = True + + def eraser_interactive_v2(self, ui_event: hou.ViewerEvent, node: hou.Node) -> None: + """The logic for erasing as a stroke, and opening an eraser-specific undo block.""" + if ( + ui_event.reason() == hou.uiEventReason.Active + or ui_event.reason() == hou.uiEventReason.Start + ): + if self.first_hit is True: + self.undoblock_open("Eraser") + self.first_hit = False + + if not self.cursor_adv.is_hit and not self.cursor_adv.hit_prim >= 0: + return + intersect_geometry = self.get_intersection_geometry(node) + + # get the intersecting prim from the cursor, to delete prim seg + geo_prim = intersect_geometry.prim(self.cursor_adv.hit_prim) + + if geo_prim is None: + return + + # seg_id = geo_prim.attribValue("seg_id") + stroke_id = geo_prim.attribValue("stroke_id") + + # load in the stroke buffer geometry + stroke_data_parm = node.parm(self.get_strokecache_parm_name()) + cache_geo = stroke_data_parm.evalAsGeometry() + + new_geo = hou.Geometry() + new_geo.merge(cache_geo) + + if self.eraser_fullstroke: + stroke_id_values = new_geo.primIntAttribValues("stroke_id") + + stroke_id_np = np.array(stroke_id_values) + matched_prims = np.where(stroke_id_np == stroke_id)[0] + + search_str = " ".join(map(str, matched_prims)) + + deletion_prims = new_geo.globPrims(search_str) + else: + deletion_prims = new_geo.globPrims(str(self.cursor_adv.hit_prim)) + + new_geo.deletePrims(deletion_prims) + + if new_geo: + stroke_data_parm.set(new_geo) + + elif ui_event.reason() == hou.uiEventReason.Changed: + self.undoblock_close() + + self.first_hit = True + + def resize_cursor(self, node: hou.Node, dist: float) -> None: + """Adjusts the current stroke radius by a requested bump. + + Used internally. + """ + scale = pow(1.01, dist) + stroke_radius = node.parm(self.get_radius_parm_name()) + + rad = stroke_radius.evalAsFloat() + rad *= scale + + stroke_radius.set(rad) + self.cursor_adv.update_xform({"scale": (rad, rad, rad)}) + + def to_hbytes(self, mirror_data) -> bytes: + """Encodes the list of StrokeData as an array of bytes. + + This byte stream is expected by the stroke SOP's raw data parameter.. + """ + # log_stroke_event(f"Byte stream encoded for mirror data: `{mirror_data}`") + + stream = vsu.ByteStream() + stream.add(StrokeData.VERSION, int) + stream.add(len(self.strokes), int) + stream.add(mirror_data, vsu.ByteStream) + return stream.data() + + def reset_active_stroke(self): + self.strokes = [] + self.strokes_mirror_data = [] + self.strokes_next_to_encode = 0 + + def apply_stroke(self, node: hou.Node, update: bool) -> None: + """Updates the stroke multiparameter from the current self.strokes information. + + Each stroke is a StrokeData object that consists of an updated per-point position + of each mid-stroke evaluation. + + Stroke mirror data consists of the Stroke bytestream of incoming position data + converted to binary encodings for the stroke SOP. + + Parameters: + node: hou.Node + The node to evaluate stroke parameters on. + update: bool + Bool for if the stroke is being updated, or is starting a new stroke. + """ + stroke_numstrokes_param = node.parm("stroke_numstrokes") + + # Performs the following as undoable operations + with hou.undos.group("Draw Stroke"): + stroke_numstrokes = stroke_numstrokes_param.evalAsInt() + + mirrorlist = self.active_mirror_transforms() + + if stroke_numstrokes == 0 or not update: + stroke_numstrokes += len(mirrorlist) + + stroke_numstrokes_param.set(stroke_numstrokes) + + activestroke = stroke_numstrokes - len(mirrorlist) + 1 + + if self.strokes_next_to_encode > len(self.strokes): + self.strokes_next_to_encode = 0 + self.strokes_mirror_data = [] + + self.get_stroke_mirror_bytestream(mirrorlist) + + for mirror, mirror_data in zip(mirrorlist, self.strokes_mirror_data): + stroke_meta_data = StrokeMetaData.create(self.last_meta_data_array) + + if self.screendraw_enabled: + stroke_dir = self.last_sd_dir + else: + stroke_dir = self.mouse_dir + + params = self.build_default_stroke_params( + node, + activestroke, + stroke_dir, + self.last_stroke_color, + self.last_stroke_opacity, + self.last_stroke_projcenter, + self.last_stroke_projtype, + self.last_stroke_radius, + self.last_stroke_tool, + ) + + mirroredstroke = StrokeData.create() + for i in range(self.strokes_next_to_encode, len(self.strokes)): + stroke = self.strokes[i] + self.assign_mirrored_stroke_defaults(mirror, mirroredstroke, stroke) + + if self.screendraw_enabled: + self.assign_dplane_to_mirroredstroke(mirroredstroke, stroke_dir) + else: + self.assign_cursor_intersection_to_mirroredstroke( + mirroredstroke + ) + + if mirroredstroke.hit: + self.last_intersection_pos = mirroredstroke.proj_pos + + # log_stroke_event(f"Mirrored stroke pre-encode: `{mirroredstroke.__dict__}`") + mirror_data.add(mirroredstroke.encode(), vsu.ByteStream) + + bytedata_decoded = self.to_hbytes(mirror_data).decode("utf-8") + params.data.set(bytedata_decoded) + + try: + params.metadata.set(stroke_meta_data) + except AttributeError: + log_stroke_event(f"Could not set metadata parameter") + pass + + self.strokes_next_to_encode = len(self.strokes) + + def assign_dplane_to_mirroredstroke( + self, mirroredstroke: StrokeData, stroke_dir: hou.Vector3 + ) -> None: + # mouse_pos = self.strokes[-1].pos + # proj_dir = self.mouse_dir + # proj_dir = proj_dir.normalized() + # + # proj_pos = mouse_pos + (proj_dir * self.last_sd_dist) + + mirroredstroke.proj_pos = self.last_sd_hp + mirroredstroke.proj_uv = self.cursor_adv.last_uvw + mirroredstroke.proj_prim = self.cursor_adv.hit_prim + mirroredstroke.hit = self.cursor_adv.is_hit + + def assign_cursor_intersection_to_mirroredstroke( + self, mirroredstroke: StrokeData + ) -> None: + mirroredstroke.proj_pos = self.cursor_adv.last_cursor_pos + mirroredstroke.proj_uv = self.cursor_adv.last_uvw + mirroredstroke.proj_prim = self.cursor_adv.hit_prim + mirroredstroke.hit = self.cursor_adv.is_hit + + def assign_mirrored_stroke_defaults( + self, mirror: hou.Matrix4, mirroredstroke: StrokeData, stroke: StrokeData + ) -> None: + mirroredstroke.reset() + mirroredstroke.pos = stroke.pos * mirror + dir4 = hou.Vector4(stroke.dir) + dir4[3] = 0 + dir4 = dir4 * mirror + mirroredstroke.dir = hou.Vector3(dir4) + mirroredstroke.proj_pos = (hou.Vector3(0.0, 0.0, 0.0),) + mirroredstroke.proj_uv = (hou.Vector3(0.0, 0.0, 0.0),) + mirroredstroke.proj_prim = (-1,) + mirroredstroke.pressure = stroke.pressure + mirroredstroke.time = stroke.time + mirroredstroke.tilt = stroke.tilt + mirroredstroke.angle = stroke.angle + mirroredstroke.roll = stroke.roll + + def build_default_stroke_params( + self, + node: hou.Node, + activestroke: int, + proj_dir: hou.Vector3, + stroke_color: hou.Vector3, + stroke_opacity: float, + stroke_projcenter: hou.Vector3, + stroke_projtype: int, + stroke_radius: float, + stroke_tool: int, + ) -> StrokeParams: + params = StrokeParams(node, activestroke) # What does activestroke do? + # activestroke = activestroke + 1 + # Setting Stroke Params + params.radius.set(stroke_radius) + params.opacity.set(stroke_opacity) + params.tool.set(stroke_tool) + (r, g, b) = stroke_color.rgb() + params.colorr.set(r) + params.colorg.set(g) + params.colorb.set(b) + params.projtype.set(stroke_projtype) + params.projcenterx.set(stroke_projcenter[0]) + params.projcentery.set(stroke_projcenter[1]) + params.projcenterz.set(stroke_projcenter[2]) + params.projdirx.set(proj_dir[0]) + params.projdiry.set(proj_dir[1]) + params.projdirz.set(proj_dir[2]) + return params + + def get_stroke_mirror_bytestream(self, mirrorlist: list[StrokeData]) -> None: + extra_mirrors = len(mirrorlist) - len(self.strokes_mirror_data) + if extra_mirrors > 0: + self.strokes_mirror_data.extend( + [vsu.ByteStream() for _ in range(extra_mirrors)] + ) + + def get_stroke_defaults(self, node): + self.last_stroke_radius = _eval_param(node, self.get_radius_parm_name(), 0.1) + self.last_stroke_opacity = _eval_param(node, "stroke_opacity", 1) + self.last_stroke_tool = _eval_param(node, "stroke_tool", -1) + self.last_stroke_color = _eval_param_c( + node, "stroke_colorr", "stroke_colorg", "stroke_colorb", (1, 1, 1) + ) + if self.screendraw_enabled: + # self.last_stroke_projtype = _eval_param(node, "stroke_projtype", 0) + self.last_stroke_projtype = 3 + else: + self.last_stroke_projtype = 4 + self.last_stroke_projcenter = _eval_param_v3( + node, + "stroke_projcenterx", + "stroke_projcentery", + "stroke_projcenterz", + (0, 0, 0), + ) + + def stroke_from_event( + self, ui_event: hou.ViewerEvent, device: hou.UIEventDevice, node: hou.Node + ) -> StrokeData: + """Create a stroke data struct from a UI device event and mouse point projection on the geometry + + Used internally. + """ + # log_stroke_event(f"Stroke from event: ui_event: `{ui_event}`, device: `{device}`, node: `{node}`") + + sdata = StrokeData.create() + if self.screendraw_enabled: + (mouse_point, mouse_dir) = self.get_ui_centre(ui_event) + else: + (mouse_point, mouse_dir) = ui_event.screenToRay( + device.mouseX(), device.mouseY() + ) + + sdata.pos = mouse_point + sdata.dir = mouse_dir + sdata.pressure = device.tabletPressure() + sdata.tile = device.tabletTilt() + sdata.angle = device.tabletAngle() + sdata.roll = device.tabletRoll() + + if device.time() >= 0: + sdata.time = device.time() - self.epoch_time + else: + sdata.time = self.stopwatch.elapsed() + + ( + sdata.proj_pos, + _, + sdata.proj_uv, + sdata.proj_prim, + sdata.hit, + ) = project_point_dir( + node, sdata.pos, sdata.dir, self.get_intersection_geometry(node) + ) + return sdata + + def handle_stroke_event(self, ui_event: hou.ViewerEvent, node: hou.Node) -> None: + """Registers stroke event(s) and deals with the queued devices. + + Used internally. + """ + first_device = ui_event.device() + if ui_event.hasQueuedEvents() is True: + first_device = ui_event.queuedEvents()[0] + + if len(self.strokes) == 0: + if first_device.time() >= 0: + self.epoch_time = first_device.time() + else: + # self.stopwatch.stop() + self.stopwatch.start() + + for qevent in ui_event.queuedEvents(): + sd = self.stroke_from_event(ui_event, qevent, node) + self.strokes.append(sd) + + sd = self.stroke_from_event(ui_event, ui_event.device(), node) + + if ui_event.reason() == hou.uiEventReason.Changed and self.strokes: + sd.pressure = self.strokes[-1].pressure + sd.tilt = self.strokes[-1].tilt + sd.angle = self.strokes[-1].angle + sd.roll = self.strokes[-1].roll + + self.strokes.append(sd) + + def cache_strokes(self, node: hou.Node) -> None: + """Store the drawn stroke in the data parameter. + + Used with post-stroke callback. + """ + new_geo = hou.Geometry() + stroke_data_parm = node.parm(self.get_strokecache_parm_name()) + + cache_geo = stroke_data_parm.evalAsGeometry() + + if cache_geo: + new_geo.merge(cache_geo) + + incoming_stroke = node.node("STROKE_PROCESSED").geometry() + + if incoming_stroke: + new_geo.merge(incoming_stroke) + + self.set_max_strokes_global(node, new_geo) + + clear_geo_groups(new_geo) + + stroke_data_parm.set(new_geo) + + def clear_strokecache(self, node: hou.Node) -> None: + """Delete the contents of the hpaint data parm.""" + stroke_data_parm = node.parm(self.get_strokecache_parm_name()) + + blank_geo = hou.Geometry() + + clear_geo_groups(blank_geo) + + stroke_data_parm.set(blank_geo) + + def reset_stroke_parms(self, node: hou.Node) -> None: + """Delete the parm strokes from the stroke SOP.""" + node.parm("stroke_numstrokes").set(0) + + def add_stroke_num(self, node: hou.Node) -> None: + """Add to the internal stroke counter on HDA used for group IDs.""" + + strokenum_parm = node.parm(self.get_strokenum_parm_name()) + + stroke_count = strokenum_parm.evalAsInt() + + stroke_count += 1 + + strokenum_parm.set(stroke_count) + + def update_brush_type(self, ui_event) -> None: + self.depthpicker_enabled = False + self.colourpicker_enabled = False + """Turn on the eraser when ctrl is pressed uses eraser_enabled to bool eraser on.""" + if ui_event.device().isCtrlKey(): + self.eraser_enabled = True + if ui_event.device().isShiftKey(): + self.eraser_fullstroke = True + return + self.eraser_fullstroke = False + return + elif ui_event.device().isMiddleButton(): + self.eraser_enabled = False + if ui_event.device().isShiftKey(): + self.depthpicker_enabled = True + else: + self.colourpicker_enabled = True + self.eraser_enabled = False + + def is_pressure_enabled(self) -> bool: + """Check whether or not pressure has been enabled on HDA. + + This is used to determine cursor radius. + """ + + return self.pressure_enabled + + def generate_text_drawable(self, scene_viewer: hou.SceneViewer) -> dict: + """Generate all of the parameters used with the Hpaint text drawable. + + This currently uses CSS tags with which may + end up being deprecated later on. + """ + + (x, y, width, height) = scene_viewer.curViewport().size() + margin = 10 + + asset_title = self.format_drawable_text( + text=f"HPaint {HDA_VERSION}", size=4, bold=True + ) + + asset_artist = self.format_drawable_text(text=HDA_AUTHOR) + + asset_tips = [ + "Erase: CTRL", + "Erase Stroke: CTRL + SHIFT", + "Move brush up/down in N steps: [ or ]", + "Save Buffer to Disk: SHIFT + S", + ] + + text_content = "
".join([asset_title] + [asset_artist] + asset_tips) + text_params = { + "text": text_content, + "multi_line": True, + "color1": hou.Color(1.0, 1.0, 0.0), + "translate": hou.Vector3(0, height, 0), + "origin": hou.drawableTextOrigin.UpperLeft, + "margins": hou.Vector2(margin, -margin), + } + return text_params + + def format_drawable_text( + self, text: Any, size: int = 3, color: str = "yellow", bold: bool = False + ): + """Wrap a string in the text tags for the text drawable""" + if bold: + text = f"{text}" + + return f"{text}" + + def set_max_strokes_global(self, node: hou.Node, input_geo: hou.Geometry) -> None: + """Saves the current HDA stroke counter value as a global on outgoing strokes. + + Used when strokes are cached. + """ + if input_geo is not None: + maxiter_parm = node.parm("hp_stroke_num") + maxiter_value = maxiter_parm.evalAsInt() + + global_name = "max_strokeid" + + self.set_global_attrib(input_geo, global_name, maxiter_value, -1) + + def set_global_attrib( + self, input_geo: hou.Geometry, attrib_name: str, value: Any, default_value: Any + ) -> None: + """Helper function to assign global attributes""" + if input_geo.findGlobalAttrib(attrib_name) is None: + input_geo.addAttrib(hou.attribType.Global, attrib_name, default_value) + input_geo.setGlobalAttribValue(attrib_name, value) + + def shift_surface_dist(self, node: hou.Node, direction_id: int) -> None: + """Changes the Stroke Surface Distance parameter when the brackets are pressed. + + Each bracket press gives the specified shift in one direction. + """ + + # edit this value to edit how the shift is applied + shift_val = 0.005 + + sdist_parm = node.parm("hp_stroke_sdist") + + sdist_parm_val = sdist_parm.evalAsFloat() + + # 0 = down, 1 = up + if direction_id == 0: + result_val = sdist_parm_val - shift_val + + if result_val <= 0.0: + result_val = 0.0 + + sdist_parm.set(result_val) + else: + result_val = sdist_parm_val + shift_val + + sdist_parm.set(result_val) + + def undoblock_open(self, block_name: str) -> None: + """Open up an undo block safely without chance of a conflict""" + if self.undo_state == 0: + try: + self.cursor_adv.scene_viewer.beginStateUndo(block_name) + self.undo_state = 1 + except hou.OperationFailed: + return + elif self.undo_state: + self.cursor_adv.scene_viewer.endStateUndo() + try: + self.cursor_adv.scene_viewer.beginStateUndo(block_name) + self.undo_state = 1 + except hou.OperationFailed: + return + + def undoblock_close(self) -> None: + """Close the active undo block and prevent a new undo block from being generated""" + if self.undo_state == 0: + return + elif self.undo_state: + self.cursor_adv.scene_viewer.endStateUndo() + self.undo_state = 0 + + +def world_to_ndc(viewport: hou.GeometryViewport, point: hou.Vector3) -> hou.Vector3: + # Convert the point from world coordinates to viewport coordinates + viewport_point = viewport.mapToScreen(point) + + # Get the transform from viewport to NDC + viewport_to_ndc = viewport.viewportToNDCTransform() + + # Convert the point to homogeneous coordinates + point_homogeneous = hou.Vector4(viewport_point.x(), viewport_point.y(), 0, 1) + + # Transform the point to NDC space + ndc_point_homogeneous = point_homogeneous.__mul__(viewport_to_ndc) + + # Perform perspective division to convert from homogeneous to Cartesian coordinates + ndc_point = hou.Vector3(ndc_point_homogeneous) / ndc_point_homogeneous.w() + + return ndc_point + + +def log_stroke_event( + log_string: str, use_print: bool = False, level: int = logging.DEBUG +) -> None: + if use_print: + print(f"{log_string}") + else: + logging.log(level=level, msg=log_string) + + +def clear_geo_groups(geo: hou.Geometry) -> None: + if geo is None: + return + + for group in geo.primGroups(): + if group.primCount() < 1: + try: + group.destroy() + except hou.GeometryPermissionError: + log_stroke_event(f"Could not destroy group `{group}`.") + + +def createViewerStateTemplate(): + """Mandatory entry point to create and return the viewer state + template to register. + + This contains the standardised keyword arguments 'kwargs' for the createViewerStateTemplate function + specified in the Houdini documentation. + """ + + state_typename = kwargs["type"].definition().sections()["DefaultState"].contents() + state_label = "aaron_smith::hpaint::{0}".format(HDA_VERSION) + state_cat = hou.sopNodeTypeCategory() + + template = hou.ViewerStateTemplate(state_typename, state_label, state_cat) + template.bindFactory(State) + template.bindIcon(kwargs["type"].icon()) + + # hotkeys for menu + press_save_to_file = vsu.hotkey( + state_typename, "press_save_to_file", "shift+s", "Save Buffer to Disk" + ) + press_clear_buffer = vsu.hotkey( + state_typename, "press_clear_buffer", "shift+c", "Clear Stroke Buffer" + ) + toggle_guide_vis = vsu.hotkey( + state_typename, "toggle_guide_vis", "g", "Toggle Guide Visibility" + ) + toggle_screen_draw = vsu.hotkey( + state_typename, "toggle_screen_draw", "shift+d", "Toggle Screen Draw" + ) + + stroke_sdshift_down = vsu.hotkey( + state_typename, "stroke_sdshift_down", "[", "Shift Surface Dist Down" + ) + stroke_sdshift_up = vsu.hotkey( + state_typename, "stroke_sdshift_up", "]", "Shift Surface Dist Up" + ) + + action_by_group = vsu.hotkey( + state_typename, "action_by_group", "a", "Toggle Action By Group" + ) + + # add menu for hpaint commands + hpaint_menu = hou.ViewerStateMenu("hpaint_menu", "Hpaint settings...") + + hpaint_menu.addActionItem( + "press_save_to_file", "Save Buffer to Disk", hotkey=press_save_to_file + ) + hpaint_menu.addActionItem( + "press_clear_buffer", "Clear Stroke Buffer", hotkey=press_clear_buffer + ) + hpaint_menu.addActionItem( + "toggle_guide_vis", "Toggle Guide Visibility", hotkey=toggle_guide_vis + ) + hpaint_menu.addActionItem( + "toggle_screen_draw", "Toggle Screen Draw", hotkey=toggle_screen_draw + ) + + # shift the stroke surface distance up or down + hpaint_menu.addActionItem( + "stroke_sdshift_down", "Shift Surface Dist Down", hotkey=stroke_sdshift_down + ) + hpaint_menu.addActionItem( + "stroke_sdshift_up", "Shift Surface Dist Up", hotkey=stroke_sdshift_up + ) + + hpaint_menu.addActionItem( + "action_by_group", "Toggle Action By Group", hotkey=action_by_group + ) + + template.bindMenu(hpaint_menu) + + return template diff --git a/hda_py/hpaint_help.txt b/hda_py/hpaint_help.txt new file mode 100644 index 0000000..92df39a --- /dev/null +++ b/hda_py/hpaint_help.txt @@ -0,0 +1,193 @@ +#type: node +#context: sop +#internal: hpaint +#icon: SOP/paintsdfvolume + += Hpaint = + +"""A tool for drawing with geometry in the viewport.""" + +The Hpaint SOP is a viewport drawing utility for Houdini 19.5, allowing you to digitally paint on any geometry. + +== Hpaint Viewer State Keys == (keys) + +:task: Toggle screen draw mode: + ((Shift + D)) + +:task: Toggle guide geo visibility: + ((G)) + +:task: Draw strokes: + ((LMB)) + +:task: Erase strokes: + ((Ctrl + LMB)) + +:task: Delete entire strokes: + ((Ctrl + Shift + LMB)) + +:task: Change the brush radius: + ((Shift + LMB)) + +:task: Sample using the Colour Picker: + ((MMB)) + +:task: Sample using the Depth Picker (Screen Draw Mode): + ((Shift + MMB)) + +:task: Shift stroke surface distance down: + (([)) + +:task: Shift stroke surface distance up: + ((])) + +:task: Toggle action by group: + ((A)) + +:task: Save the current stroke buffer to disk: + ((Shift + S)) + +:task: Clear the current stroke buffer: + ((Shift + C)) + + +== Recorded attributes == + +Hpaint will record almost all of the attributes provided by the [Stroke SOP|Node:sop/stroke], with some added extras. + +Point: + `arclen`: + The length of the initial stroke line. + + `surface_distance_add`: + The value of the Stroke Surface Distance parameter applied to the stroke. + + +Prim: + `seg_id`: + The primitive segment number within the current stroke. + + `stroke_id`: + The stroke ID counted within the current Hpaint SOP. + + +@parameters +=== Stroke Utilities === + +Clear Stroke Buffer: + Removes all strokes currently held in the stroke buffer. + +Save Buffer to Disk: + Saves all strokes currently held in the stroke buffer to the file path specified. + + WARNING: + Saving the buffer to disk will not overwrite the currently loaded disk file, but will merge into it. + +File Path: + The path on disk to save to. + + TIP: + Use frame-evaluating expressions such as `$F` to create frame-by-frame animations! + + :box: + #display: rounded green + + Combine this with the timeshift SOP and template flag to give yourself basic onion-skinning functionality. + + :box: + #display: rounded green + + For simple expression functionality, name your expressions with their associated name (e.g Eye_open). + + Link your file name to an Ordered Menu parameter, and key between your expressions for a Toonboom-esque method! + +Clear Stroke File: + Pressing [Icon:PARTS/delete_x] clears the specified buffer disk file of all strokes, but does not delete the file itself. + +Swap Disk File into Buffer: + Swaps strokes from the specified disk file into the stroke buffer, clearing the original disk file. + + TIP: + Swapping the disk file into the buffer allows you to erase unwanted strokes from a cache on disk, and then save it back again using Save Buffer to Disk. + +Action By Group: + Enables unix-style pattern matching for Houdini groups on `Clear Stroke Buffer`, `Swap Disk File into Buffer` and `Clear Stroke File`. For example, typing `l*` and clearing the stroke buffer would clear all strokes that have houdini groups starting with l. + +=== Stroke Settings === + +Stroke Colour: + Used to control the `Cd` and `Alpha` of resulting strokes. + +Stroke Radius: + Used to control the width of resulting strokes. + +Stroke Surface Distance: + Used to control the distance of resulting strokes from the surface of the input geometry. + +Stroke Texture: + If enabled, used to specify the shader texture applied to all visible strokes. + +Stroke Group: + If enabled, used to specify the prim group of resulting strokes. + +=== Tool Settings === + +Enable Screen Draw: + #id: hp_sd_enable + Toggles the drawable object from the input geometry to a grid in camera space. + +Screen Draw Depth Method: + #id: hp_sd_type + Controls the way that depth is used when drawing a stroke in Screen Draw mode. `Once` will create strokes at the sampled viewport depth of the current viewport geometry, whereas `Continuous` will draw at the depth specified in the Screen Draw Distance parameter. + +Screen Draw Distance: + #id: hp_sd_dist + The distance of the camera grid, from the selected camera. + +Enable Stroke Pressure: + Determines if tablet pressure is used in resulting strokes' construction. + +Enable Pressure Width: + Determines if tablet pressure is used in calculating resulting strokes' width. + +Enable Pressure Alpha: + Determines if tablet pressure is used in calculating resulting strokes' alpha. + +Stroke Subdivs: + Used to control the level of subdivision applied to resulting strokes. + +Disable Geometry Mask: + #id: disable_geo_mask + A toggle for disabling the automated stroke masking with a geometry input. When Geometry Masking is disabled, all strokes that leave the surface of the geometry will be evaluated at the depth plane of the last surface position, angled by the current camera view. When the stroke returns to the surface of the geometry, it is re-evaluated in a way that keeps the look of the stroke natural. + +Output Curves Only: + #id: output_curves + A toggle for drawing only curves with HPaint. When enabled, strokes are not turned into their UVed card counterparts and are instead left as polylines. + +=== Anim Settings === + +Display Nearest Frame: + Used to toggle how a time-dependent stroke cache is displayed. Using `Nearest Method`, snap an animation to either its' last valid frame, or next valid frame. + +Ghost if Empty Frame: + If the underlying file cache is currently visible as the last or next frame, but does not match the current frame, a ghosting effect (multiplication of `Alpha`) is applied. + +=== Visualization === + +Hide Guide Geometry: + Removes the guide geometry from the viewport when using HPaint. + +Use Full Geometry As Guide: + Replaces the visualizer wireframe guide geometry with the original input geometry. + + NOTE: + A default Prim `Cd` and Point `Alpha` attrib value will be applied to the input geometry, if either attribute does not exist. + +Guide Colour: + The colour and alpha value of the geometry wireframe guide. + + +"""Aaron Smith 2021""" + +@related + - [Node:sop/stroke] \ No newline at end of file diff --git a/otls/.gitkeep b/otls/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/otls/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/otls/README.md b/otls/README.md deleted file mode 100644 index 0816351..0000000 --- a/otls/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# HPaint Changelog 1.2 - -- Updated for Houdini 19, now compatible with both py2 and py3 builds -- Fixed bug where camera plane normals were reversed \ No newline at end of file diff --git a/otls/aaron_smith__hpaint__1_2.hdalc b/otls/aaronsmithtv__hpaint__2.0.hda similarity index 68% rename from otls/aaron_smith__hpaint__1_2.hdalc rename to otls/aaronsmithtv__hpaint__2.0.hda index a5b76f0..4a4648a 100644 Binary files a/otls/aaron_smith__hpaint__1_2.hdalc and b/otls/aaronsmithtv__hpaint__2.0.hda differ