OverviewFilesCommitsIssuesDocsReleasesPeers

hardops commits / a1ec46e

docs: fork README and the headless regression scripts
FORK.md states the vendor-drop workflow (vendor commits stay pristine, ours sit on top, so vendor..HEAD is exactly what we carry) and the three update remotes. Adds the two Blender headless scripts used to verify the 5.2 work so a future vendor merge can be checked the same way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a1ec46ec3487ca4424f02c1c531efb889f0e97e3
by plankatron <fleeky@gmail.com> · 2026-08-07T21:10:47.000Z

Changed files (3) +313 -0

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