| … | … | @@ -0,0 +1,70 @@ |
| 1 | # HardOps fork | |
| 2 | ||
| 3 | A tracking fork of Hard Ops (paid addon, GPL-3.0-or-later) carrying local fixes | |
| 4 | that upstream has not shipped. **This directory is the live Blender extension | |
| 5 | directory** — `~/.config/blender/5.2/extensions/user_default/hardops` — so edits | |
| 6 | here take effect on the next Blender start and are versioned in place. | |
| 7 | ||
| 8 | ## Layout | |
| 9 | ||
| 10 | History alternates two kinds of commit: | |
| 11 | ||
| 12 | - **`vendor: HardOps <version>`** — a pristine upstream drop, extracted from the | |
| 13 | Blender Market zip, nothing else in the commit. | |
| 14 | - **everything else** — our fixes, on top of the most recent vendor commit. | |
| 15 | ||
| 16 | That keeps `git diff <vendor-commit>..HEAD` an exact statement of what we carry. | |
| 17 | ||
| 18 | ## Taking a new vendor release | |
| 19 | ||
| 20 | ```bash | |
| 21 | cd ~/.config/blender/5.2/extensions/user_default/hardops | |
| 22 | git checkout -b vendor-<version> # keep main clean until it builds | |
| 23 | rsync -a --delete --exclude=.git --exclude=.gitignore --exclude=FORK.md \ | |
| 24 | /path/to/unzipped/HOps/ . | |
| 25 | git add -A && git commit -m "vendor: HardOps <version>" | |
| 26 | git checkout main && git merge vendor-<version> # conflicts = the files we patch | |
| 27 | ``` | |
| 28 | ||
| 29 | Resolve conflicts by checking whether upstream fixed the thing we patched. If | |
| 30 | they did, drop our hunk. If not, keep it. Then re-run the regression checks | |
| 31 | below before pushing. | |
| 32 | ||
| 33 | ## Regression checks | |
| 34 | ||
| 35 | The 5.2 work was verified headless. Rerun after any vendor merge: | |
| 36 | ||
| 37 | ```bash | |
| 38 | blender --background --python test_bool.py # auto smooth + boolean + wrappers | |
| 39 | blender --background --python test_rest.py # radial array, to_thread, attributes | |
| 40 | ``` | |
| 41 | ||
| 42 | Both scripts stub `ui_framework.operator_ui.Master.draw` (headless has no | |
| 43 | `context.area`) and call `gpu.init()` before enabling the addon. | |
| 44 | ||
| 45 | ## Update channels | |
| 46 | ||
| 47 | Three remotes reach the same forge repo. Any of them can serve an update; pick | |
| 48 | whichever is reachable. | |
| 49 | ||
| 50 | ```bash | |
| 51 | git remote -v | |
| 52 | # forge /home/fleeky/git-repos/hardops.git local bare repo (this machine) | |
| 53 | # web https://7rnx.net/git/hardops.git public HTTPS | |
| 54 | # p2p punch://473128f3…/hardops Hyperswarm, HTTPS fallback | |
| 55 | ||
| 56 | git pull p2p main # or web, or forge | |
| 57 | ``` | |
| 58 | ||
| 59 | `punch://` prints `Connected via P2P!` when it finds a peer and silently falls | |
| 60 | back to HTTPS when it cannot. Forge backends replicate the repo between | |
| 61 | themselves over the swarm — a push to one backend materialises | |
| 62 | `~/git-repos/hardops.git` on the others within minutes, with no push to them. | |
| 63 | ||
| 64 | Browse at <https://7rnx.net/git/repos/hardops>. | |
| 65 | ||
| 66 | ## Caveat | |
| 67 | ||
| 68 | Installing a vendor zip through Blender's own *Install from Disk* **replaces | |
| 69 | this directory**, taking `.git` with it. Prefer the rsync-over-checkout flow | |
| 70 | above. If it does get wiped, re-clone from any of the three remotes. |
| … | … | @@ -0,0 +1,104 @@ |
| 1 | import bpy, sys, math, gpu | |
| 2 | ||
| 3 | gpu.init() | |
| 4 | bpy.ops.preferences.addon_enable(module='bl_ext.user_default.hardops') | |
| 5 | ||
| 6 | from bl_ext.user_default.hardops.utility import object as hops_object | |
| 7 | from bl_ext.user_default.hardops.utility import addon | |
| 8 | from bl_ext.user_default.hardops.utility import geometry_nodes | |
| 9 | from bl_ext.user_default.hardops.ui_framework.operator_ui import Master | |
| 10 | ||
| 11 | # headless has no context.area, so the operator's viewport HUD can't draw | |
| 12 | Master.draw = lambda self, *args, **kwargs: None | |
| 13 | ||
| 14 | print("\nRNA_INPUTS:", geometry_nodes.compat.RNA_INPUTS) | |
| 15 | ||
| 16 | # --- the reported crash path: get_auto_smooth --- | |
| 17 | bpy.ops.mesh.primitive_cube_add(size=2) | |
| 18 | target = bpy.context.active_object | |
| 19 | target.name = 'target' | |
| 20 | bpy.ops.object.shade_auto_smooth(angle=math.radians(37)) | |
| 21 | ||
| 22 | print("get_auto_smooth:", hops_object.get_auto_smooth(target)) | |
| 23 | assert hops_object.get_auto_smooth(target)[0] is True | |
| 24 | assert abs(math.degrees(hops_object.get_auto_smooth(target)[1]) - 37) < 0.01 | |
| 25 | ||
| 26 | hops_object.set_auto_smooth(target, True, angle=math.radians(50)) | |
| 27 | assert abs(math.degrees(hops_object.get_auto_smooth(target)[1]) - 50) < 0.01 | |
| 28 | print("set_auto_smooth OK") | |
| 29 | ||
| 30 | # --- the operator that crashed, on both workflows --- | |
| 31 | def run_difference(workflow_modifier): | |
| 32 | addon.preference().property.workflow_modifier = workflow_modifier | |
| 33 | ||
| 34 | bpy.ops.mesh.primitive_cube_add(size=1, location=(0.5, 0, 0)) | |
| 35 | cutter = bpy.context.active_object | |
| 36 | cutter.name = 'cutter_' + workflow_modifier | |
| 37 | ||
| 38 | bpy.ops.object.select_all(action='DESELECT') | |
| 39 | cutter.select_set(True) | |
| 40 | target.select_set(True) | |
| 41 | bpy.context.view_layer.objects.active = target | |
| 42 | ||
| 43 | # execute() skips invoke(), which is what normally sets self.operator | |
| 44 | result = bpy.ops.hops.bool_difference(operator='DIFFERENCE') | |
| 45 | print(f"\nbool_difference [{workflow_modifier}] ->", result) | |
| 46 | assert result == {'FINISHED'}, result | |
| 47 | return cutter | |
| 48 | ||
| 49 | run_difference('CLASSIC') | |
| 50 | cutter = run_difference('NODES') | |
| 51 | ||
| 52 | # --- the cut actually evaluates, before any round-trip mutation --- | |
| 53 | dg = bpy.context.evaluated_depsgraph_get() | |
| 54 | me = target.evaluated_get(dg).to_mesh() | |
| 55 | print("evaluated verts:", len(me.vertices), "polys:", len(me.polygons)) | |
| 56 | assert len(me.polygons) > 6, 'boolean did not cut' | |
| 57 | ||
| 58 | # --- inspect the geometry nodes boolean wrapper --- | |
| 59 | for mod in target.modifiers: | |
| 60 | wrapper = geometry_nodes.NodeBoolean.new(mod) | |
| 61 | if not wrapper: | |
| 62 | continue | |
| 63 | ||
| 64 | print("\nHOPS node boolean:", mod.name) | |
| 65 | print(" operation:", wrapper.operation) | |
| 66 | print(" object :", wrapper.object) | |
| 67 | print(" solver :", wrapper.solver) | |
| 68 | print(" inset_thickness:", wrapper.inset_thickness) | |
| 69 | print(" weld:", wrapper.weld, "merge_threshold:", wrapper.merge_threshold) | |
| 70 | ||
| 71 | assert wrapper.operation == 'DIFFERENCE', wrapper.operation | |
| 72 | assert wrapper.object is cutter, wrapper.object | |
| 73 | ||
| 74 | for op in ('INTERSECT', 'UNION', 'DIFFERENCE', 'INSET', 'KNIFE'): | |
| 75 | wrapper.operation = op | |
| 76 | assert wrapper.operation == op, (op, wrapper.operation) | |
| 77 | wrapper.operation = 'DIFFERENCE' | |
| 78 | ||
| 79 | for solver in ('FLOAT', 'EXACT', 'MANIFOLD'): | |
| 80 | wrapper.solver = solver | |
| 81 | assert wrapper.solver == solver, (solver, wrapper.solver) | |
| 82 | ||
| 83 | wrapper.inset_thickness = 0.25 | |
| 84 | assert abs(wrapper.inset_thickness - 0.25) < 1e-6 | |
| 85 | wrapper.weld = True | |
| 86 | assert wrapper.weld is True | |
| 87 | wrapper.tag_faces_name = 'hops_tag' | |
| 88 | assert wrapper.tag_faces_name == 'hops_tag' | |
| 89 | wrapper.material = bpy.data.materials.new('probe_mat') | |
| 90 | assert wrapper.material.name == 'probe_mat' | |
| 91 | print(" round trips OK") | |
| 92 | break | |
| 93 | else: | |
| 94 | raise AssertionError('no HOPS node boolean modifier found on target') | |
| 95 | ||
| 96 | # --- prop_draw_data, used by the bevel helper panel --- | |
| 97 | data, prop = geometry_nodes.NodeBoolean.prop_draw_data(mod, 'solver') | |
| 98 | print("\nprop_draw_data(solver):", data, prop, '->', getattr(data, prop)) | |
| 99 | assert data.bl_rna.properties[prop] is not None | |
| 100 | ||
| 101 | data, prop = geometry_nodes.NodeBoolean.prop_draw_data(mod, 'name') | |
| 102 | assert (data, prop) == (mod, 'name'), (data, prop) | |
| 103 | ||
| 104 | print("\nALL CHECKS PASSED") |
| … | … | @@ -0,0 +1,139 @@ |
| 1 | import bpy, math, gpu | |
| 2 | ||
| 3 | gpu.init() | |
| 4 | bpy.ops.preferences.addon_enable(module='bl_ext.user_default.hardops') | |
| 5 | ||
| 6 | from bl_ext.user_default.hardops.utility.geometry_nodes import compat | |
| 7 | from bl_ext.user_default.hardops.operators.modals import radial_array_nodes | |
| 8 | from bl_ext.user_default.hardops.operators.modals import ever_scroll | |
| 9 | from bl_ext.user_default.hardops.ui_framework.operator_ui import Master | |
| 10 | ||
| 11 | Master.draw = lambda self, *args, **kwargs: None | |
| 12 | ||
| 13 | print("\nRNA_INPUTS:", compat.RNA_INPUTS) | |
| 14 | ||
| 15 | # ---------------------------------------------------------------- radial array | |
| 16 | bpy.ops.mesh.primitive_cube_add() | |
| 17 | obj = bpy.context.active_object | |
| 18 | ||
| 19 | node_group = radial_array_nodes.radial_array_nodes() | |
| 20 | mod = obj.modifiers.new('HOPS_Radial_Array', 'NODES') | |
| 21 | mod.node_group = node_group | |
| 22 | ||
| 23 | array = radial_array_nodes.RadArray(mod) | |
| 24 | print("\nRadArray defaults: count", array.count, "rotation", array.rotation, "offset", array.offset) | |
| 25 | ||
| 26 | array.set_defaults() | |
| 27 | assert array.count == 3, array.count | |
| 28 | assert array.rotation == 360.0, array.rotation | |
| 29 | assert tuple(array.axis) == (0.0, 0.0, 1.0), tuple(array.axis) | |
| 30 | assert array.full_circle is True | |
| 31 | assert array.self is obj | |
| 32 | ||
| 33 | array.count = 7 | |
| 34 | assert array.count == 7 | |
| 35 | array.offset = 2.5 | |
| 36 | assert abs(array.offset - 2.5) < 1e-6 | |
| 37 | array.pivot_offset = (1.0, 2.0, 3.0) | |
| 38 | assert tuple(array.pivot_offset) == (1.0, 2.0, 3.0), tuple(array.pivot_offset) | |
| 39 | array.rotation_offset = (0.0, 0.5, 0.0) | |
| 40 | assert tuple(array.rotation_offset) == (0.0, 0.5, 0.0) | |
| 41 | array.center_pivot = True | |
| 42 | assert array.center_pivot is True | |
| 43 | print("RadArray round trips OK") | |
| 44 | ||
| 45 | dg = bpy.context.evaluated_depsgraph_get() | |
| 46 | me = obj.evaluated_get(dg).to_mesh() | |
| 47 | print("radial array evaluated verts:", len(me.vertices)) | |
| 48 | assert len(me.vertices) == 8 * 7, len(me.vertices) | |
| 49 | ||
| 50 | # ------------------------------------------------------------------ to_thread | |
| 51 | bpy.ops.mesh.primitive_cylinder_add(vertices=16, radius=0.5, depth=1.0, location=(5, 0, 0)) | |
| 52 | cyl = bpy.context.active_object | |
| 53 | bpy.ops.object.mode_set(mode='EDIT') | |
| 54 | bpy.ops.mesh.select_all(action='SELECT') | |
| 55 | # modal operators can't be invoked in background, so drive its modifier code directly | |
| 56 | from bl_ext.user_default.hardops.operators.editmode.to_thread import HOPS_OT_ToThread | |
| 57 | ||
| 58 | import types | |
| 59 | ||
| 60 | op = types.SimpleNamespace() | |
| 61 | for name in ('new_modifier', 'get_modifier', 'backup_modifier', 'restore_modifier', 'refresh_modifier', 'generate_unique_name'): | |
| 62 | setattr(op, name, types.MethodType(getattr(HOPS_OT_ToThread, name), op)) | |
| 63 | thread_mod, table = op.new_modifier(cyl) | |
| 64 | print("\nto_thread modifier:", thread_mod.name, "sockets:", len(table)) | |
| 65 | ||
| 66 | backup = op.backup_modifier(thread_mod, table) | |
| 67 | print("backup:", backup) | |
| 68 | assert backup, 'empty backup' | |
| 69 | ||
| 70 | inputs = compat.ModifierInputs(thread_mod) | |
| 71 | inputs[table['Depth']] = 0.42 | |
| 72 | inputs[table['Turns']] = 5 | |
| 73 | assert abs(inputs[table['Depth']] - 0.42) < 1e-6 | |
| 74 | ||
| 75 | op.restore_modifier(thread_mod, table, backup) | |
| 76 | assert abs(inputs[table['Depth']] - backup['Depth']) < 1e-6, (inputs[table['Depth']], backup['Depth']) | |
| 77 | print("to_thread backup/restore OK") | |
| 78 | ||
| 79 | cyl.update_from_editmode() # invoke() does this before get_modifier | |
| 80 | found_mod, found_table = op.get_modifier(cyl) | |
| 81 | assert found_mod == thread_mod, (found_mod, thread_mod) | |
| 82 | print("to_thread get_modifier OK") | |
| 83 | ||
| 84 | bpy.ops.object.mode_set(mode='OBJECT') | |
| 85 | ||
| 86 | # ---------------------------------------------------- attribute driven inputs | |
| 87 | ng = bpy.data.node_groups.new('attr_probe', 'GeometryNodeTree') | |
| 88 | ng.interface.new_socket('Geometry', in_out='INPUT', socket_type='NodeSocketGeometry') | |
| 89 | value = ng.interface.new_socket('Value', in_out='INPUT', socket_type='NodeSocketFloat') | |
| 90 | ng.interface.new_socket('Geometry', in_out='OUTPUT', socket_type='NodeSocketGeometry') | |
| 91 | out = ng.interface.new_socket('Weight', in_out='OUTPUT', socket_type='NodeSocketFloat') | |
| 92 | ||
| 93 | bpy.ops.mesh.primitive_cube_add(location=(10, 0, 0)) | |
| 94 | attr_obj = bpy.context.active_object | |
| 95 | attr_mod = attr_obj.modifiers.new('attr_probe', 'NODES') | |
| 96 | attr_mod.node_group = ng | |
| 97 | ||
| 98 | assert compat.supports_attribute(attr_mod, value.identifier) | |
| 99 | assert compat.get_use_attribute(attr_mod, value.identifier) is False | |
| 100 | ||
| 101 | data, prop = compat.value_prop(attr_mod, value.identifier) | |
| 102 | assert data is not None and data.bl_rna.properties[prop] | |
| 103 | ||
| 104 | bpy.ops.object.geometry_nodes_input_attribute_toggle(input_name=value.identifier, modifier_name=attr_mod.name) | |
| 105 | assert compat.get_use_attribute(attr_mod, value.identifier) is True, 'attribute toggle not reflected' | |
| 106 | ||
| 107 | bpy.ops.hops.helper_node_attr_set(modifier=attr_mod.name, attribute=value.identifier, value='position') | |
| 108 | assert compat.get_attribute_name(attr_mod, value.identifier) == 'position', compat.get_attribute_name(attr_mod, value.identifier) | |
| 109 | ||
| 110 | data, prop = compat.attribute_name_prop(attr_mod, value.identifier) | |
| 111 | assert getattr(data, prop) == 'position' | |
| 112 | ||
| 113 | data, prop = compat.output_attribute_name_prop(attr_mod, out.identifier) | |
| 114 | assert data is not None, 'no output attribute prop' | |
| 115 | bpy.ops.hops.helper_node_attr_set(modifier=attr_mod.name, attribute=out.identifier, value='out_attr', is_output=True) | |
| 116 | assert getattr(data, prop) == 'out_attr', getattr(data, prop) | |
| 117 | print("\nattribute inputs/outputs OK") | |
| 118 | ||
| 119 | # ---------------------------------------------------------------- ever_scroll | |
| 120 | ng2 = bpy.data.node_groups.new('objinfo_probe', 'GeometryNodeTree') | |
| 121 | ng2.interface.new_socket('Geometry', in_out='INPUT', socket_type='NodeSocketGeometry') | |
| 122 | obj_socket = ng2.interface.new_socket('Cutter', in_out='INPUT', socket_type='NodeSocketObject') | |
| 123 | ng2.interface.new_socket('Geometry', in_out='OUTPUT', socket_type='NodeSocketGeometry') | |
| 124 | ||
| 125 | group_in = ng2.nodes.new('NodeGroupInput') | |
| 126 | obj_info = ng2.nodes.new('GeometryNodeObjectInfo') | |
| 127 | ng2.links.new(group_in.outputs[obj_socket.identifier], obj_info.inputs['Object']) | |
| 128 | ||
| 129 | bpy.ops.mesh.primitive_cube_add(location=(20, 0, 0)) | |
| 130 | scroll_obj = bpy.context.active_object | |
| 131 | scroll_mod = scroll_obj.modifiers.new('objinfo_probe', 'NODES') | |
| 132 | scroll_mod.node_group = ng2 | |
| 133 | compat.ModifierInputs(scroll_mod)[obj_socket.identifier] = cyl | |
| 134 | ||
| 135 | found = ever_scroll.get_node_graph_objects(scroll_mod) | |
| 136 | print("\nget_node_graph_objects ->", found) | |
| 137 | assert found == [cyl], found | |
| 138 | ||
| 139 | print("\nALL CHECKS PASSED") |