commit 7872d294a5e3ae4bcccc809b7cd717dc87fe7906 Author: Gary Ritchie Date: Fri Apr 10 12:22:21 2026 -0400 Migrated from Stocker project diff --git a/README.md b/README.md new file mode 100644 index 0000000..5bfd7e7 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Stocker Helper + +A Blender add-on for [Stocker Geometry Nodes](httpshttps://garylritchie.gumroad.com/l/stocker). + +## Overview + +The **Stocker Helper** creates a boundary cube that encompasses selected objects. It calculates the global bounding box, generates a mesh, aligns the origin to the bottom-front-left corner, and applies Geometry Node templates. + +## Features + +* **Bounding Box Calculation:** Calculates the global spatial extent of selected mesh objects. +* **Origin Alignment:** Sets the boundary object's origin to the **Back-Left** extreme (+Y, -X). +* **Geometry Nodes Injection:** Applies a `NODES` modifier using templates named `grid_pack` or `circle_pack`. +* **Asset Fetching:** Scans registered Asset Libraries for required node groups. +* **Height Buffer Control:** Adds a percentage-based buffer to the Z-height via a slider. +* **UI:** Access via the **Stocker** tab in the 3D Viewport N-Panel. + +## Requirements + +* **Blender Version:** 4.5 +* **Node Group Templates:** The `.blend` file or the Stocker Asset Library **must** contain Geometry Node groups named `grid_pack` and `circle_pack`. + +## Installation + +1. Download `stocker.zip`. +2. Drag the `.zip` file into Blender. + +## Usage + +1. **Select Objects:** Select the objects for the packing area. +2. **Open Sidebar:** Press `N` in the 3D Viewport. +3. **Locate Tab:** Click the **Stocker** tab. +4. **Adjust Height Buffer:** Set the height buffer percentage. +5. **Run Setup:** Click **Grid Boundary** or **Circle Boundary**. +6. **Result:** An object named `Stocker_Boundary` is created with the configured Geometry Nodes modifier and origin. + +## Recent Improvements + +* **Asset Library Scan:** Implemented priority for "stocker" libraries and automatic appending of missing node groups. +* **Code Robustness:** Updated imports and classmethod poll attributes for Blender Extension compliance. + +## License + +Distributed as an open-source utility. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..bfabef1 --- /dev/null +++ b/__init__.py @@ -0,0 +1,166 @@ +import bpy + +# Import the logic from previous phases +from .scripts import phase2_geometry as logic + +bl_info = { + "name": "Stocker Helper", + "author": "Gary Ritchie", + "version": (0, 1, 0), + "blender": (4, 0, 0), + "location": "View3D > Sidebar > Stocker", + "description": "Automates boundary creation for Geometry Nodes packing", + "category": "Object", +} + +def find_and_append_node_group(name): + """Search asset libraries for a node group and append it if found.""" + import os + + if name in bpy.data.node_groups: + return bpy.data.node_groups[name] + + # Get libraries, prioritizing those starting with 'stocker' (lowercase) + libraries = list(bpy.context.preferences.filepaths.asset_libraries) + libraries.sort(key=lambda l: not os.path.basename(l.path).lower().startswith("stocker")) + + for lib in libraries: + if not lib.path or not os.path.exists(lib.path): + continue + + print(f"Stocker: Searching library {lib.path} for {name}...") + + for root, dirs, files in os.walk(lib.path): + for file in files: + if file.endswith(".blend"): + path = os.path.join(root, file) + try: + with bpy.data.libraries.load(path) as (data_from, data_to): + if name in data_from.node_groups: + data_to.node_groups = [name] + if name in bpy.data.node_groups: + print(f"Stocker: Successfully appended {name} from {path}") + return bpy.data.node_groups[name] + except: + continue + return None + +class STOCKER_PT_panel(bpy.types.Panel): + bl_label = "Stocker Helper" + bl_idname = "STOCKER_PT_panel" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = 'Stocker' + + def draw(self, context): + layout = self.layout + col = layout.column(align=True) + + # New Height Buffer slider + col.prop(context.scene, "stocker_height_buffer", text="Height Buffer (%)") + col.separator() + + op_grid = col.operator("object.stocker_setup", text="Grid Boundary") + op_grid.mode = 'GRID' + op_grid.height_buffer = context.scene.stocker_height_buffer + + op_circle = col.operator("object.stocker_setup", text="Circle Boundary") + op_circle.mode = 'CIRCLE' + op_circle.height_buffer = context.scene.stocker_height_buffer + +class STOCKER_OT_setup(bpy.types.Operator): + bl_idname = "object.stocker_setup" + bl_label = "Stocker Helper" + bl_description = "Generate a boundary cube for the selected objects" + bl_options = {'REGISTER', 'UNDO'} + + mode: bpy.props.EnumProperty( + name="Packing Mode", + description="Select the geometry nodes template to apply", + items=[ + ('GRID', "Grid Pack", "Use the Grid Pack template"), + ('CIRCLE', "Circle Pack", "Use the Circle Pack template"), + ], + default='GRID' + ) + + height_buffer: bpy.props.FloatProperty( + name="Height Buffer", + description="Percentage to add to the Z height (0.1 = 10%)", + default=0.1, + min=0.0, + max=2.0 + ) + + def execute(self, context): + # 1. Get selected objects + selected_objs = context.selected_objects + + # 2. Run logic from previous phases + bounds = logic.calculate_global_bounds(selected_objs) + if not bounds: + self.report({'ERROR'}, "No valid mesh objects selected.") + return {'CANCELLED'} + + # Apply the Z height buffer + if self.height_buffer > 0: + height_add = bounds['size'].z * self.height_buffer + bounds['size'].z += height_add + bounds['max'].z += height_add + + # 3. Create the cube + new_obj = logic.create_aligned_boundary_cube(bounds) + if not new_obj: + self.report({'ERROR'}, "Failed to create boundary cube.") + return {'CANCELLED'} + + # 4. Modifier Injection + group_name = "grid_pack" if self.mode == 'GRID' else "circle_pack" + mod_name = "grid_pack" if self.mode == 'GRID' else "circle_pack" + + # Try to get or fetch the node group with visual feedback + context.window.cursor_modal_set('WAIT') + self.report({'INFO'}, f"Searching asset libraries for {group_name}...") + + try: + node_group = find_and_append_node_group(group_name) + finally: + context.window.cursor_modal_restore() + + if not node_group: + self.report({'WARNING'}, f"Node Group '{group_name}' not found in file or Stocker Asset Libraries.") + return {'FINISHED'} + + mod = new_obj.modifiers.new(name=mod_name, type='NODES') + mod.node_group = node_group + + self.report({'INFO'}, f"Stocker setup complete using {self.mode} mode.") + return {'FINISHED'} + + @classmethod + def poll(cls, context): + return context.selected_objects + +classes = ( + STOCKER_PT_panel, + STOCKER_OT_setup, +) + +def register(): + bpy.types.Scene.stocker_height_buffer = bpy.props.FloatProperty( + name="Stocker Height Buffer", + description="Default height buffer for new boundaries", + default=0.1, + min=0.0, + max=2.0 + ) + for cls in classes: + bpy.utils.register_class(cls) + +def unregister(): + for cls in reversed(classes): + bpy.utils.unregister_class(cls) + del bpy.types.Scene.stocker_height_buffer + +if __name__ == "__main__": + register() diff --git a/__pycache__/__init__.cpython-311.pyc b/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..cae8d05 Binary files /dev/null and b/__pycache__/__init__.cpython-311.pyc differ diff --git a/scripts/__pycache__/phase1_logic.cpython-310.pyc b/scripts/__pycache__/phase1_logic.cpython-310.pyc new file mode 100644 index 0000000..46cfe58 Binary files /dev/null and b/scripts/__pycache__/phase1_logic.cpython-310.pyc differ diff --git a/scripts/__pycache__/phase2_geometry.cpython-310.pyc b/scripts/__pycache__/phase2_geometry.cpython-310.pyc new file mode 100644 index 0000000..9cb906b Binary files /dev/null and b/scripts/__pycache__/phase2_geometry.cpython-310.pyc differ diff --git a/scripts/__pycache__/phase2_geometry.cpython-311.pyc b/scripts/__pycache__/phase2_geometry.cpython-311.pyc new file mode 100644 index 0000000..beaf968 Binary files /dev/null and b/scripts/__pycache__/phase2_geometry.cpython-311.pyc differ diff --git a/scripts/phase1_logic.py b/scripts/phase1_logic.py new file mode 100644 index 0000000..ea2917f --- /dev/null +++ b/scripts/phase1_logic.py @@ -0,0 +1,44 @@ +import bpy +import mathutils + +def calculate_global_bounds(selected_objects): + if not selected_objects: + print("No objects selected.") + return None + + min_x = float('inf') + min_y = float('inf') + min_z = float('inf') + max_x = float('-inf') + max_y = float('-inf') + max_z = float('-inf') + + for obj in selected_objects: + if obj.type != 'MESH': + continue + + # Transform local bounding box corners to world space + matrix = obj.matrix_all_world = obj.matrix_world + for corner in obj.bound_box: + world_corner = matrix @ mathutils.Vector(corner) + + min_x = min(min_x, world_corner.x) + min_y = min(min_y, world_corner.y) + min_z = min(min_z, world_corner.z) + max_x = max(max_x, world_corner.x) + max_y = max(max_y, world_corner.y) + max_z = max(max_z, world_corner.z) + + if min_x == float('inf'): + print("No valid mesh objects found in selection.") + return None + + return { + 'min': (min_x, min_y, min_z), + 'max': (max_x, max_y, max_z), + 'dimensions': (max_x - min_x, max_y - min_y, max_z - min_z), + 'target_origin': (max_x, min_y, min_z) + } + +if __name__ == "__main__": + print("Phase 1: Logic verification script loaded.") diff --git a/scripts/phase2_geometry.py b/scripts/phase2_geometry.py new file mode 100644 index 0000000..f1c6842 --- /dev/null +++ b/scripts/phase2_geometry.py @@ -0,0 +1,71 @@ +import bpy +import mathutils + +def calculate_global_bounds(selected_objects): + if not selected_objects: + return None + + min_x, min_y, min_z = float('inf'), float('inf'), float('inf') + max_x, max_y, max_z = float('-inf'), float('-inf'), float('-inf') + + found_any_mesh = False + for obj in selected_objects: + if obj.type != 'MESH': + continue + + found_any_mesh = True + matrix = obj.matrix_world + for corner in obj.bound_box: + world_corner = matrix @ mathutils.Vector(corner) + + min_x = min(min_x, world_corner.x) + min_y = min(min_y, world_corner.y) + min_z = min(min_z, world_corner.z) + max_x = max(max_x, world_corner.x) + max_y = max(max_y, world_corner.y) + max_z = max(max_z, world_corner.z) + + if not found_any_mesh: + return None + + return { + 'min': mathutils.Vector((min_x, min_y, min_z)), + 'max': mathutils.Vector((max_x, max_y, max_z)), + 'size': mathutils.Vector((max_x - min_x, max_y - min_y, max_z - min_z)) + } + +def create_aligned_boundary_cube(bounds): + if not bounds: + return None + + min_v = bounds['min'] + size = bounds['size'] + + mesh = bpy.data.meshes.new("Stocker_Boundary") + obj = bpy.data.objects.new("Stocker_Boundary", mesh) + bpy.context.collection.objects.link(obj) + + verts = [ + (0, 0, 0), + (size.x, 0, 0), + (size.x, -size.y, 0), + (0, -size.y, 0), + (0, 0, size.z), + (size.x, 0, size.z), + (size.x, -size.y, size.z), + (0, -size.y, size.z) + ] + faces = [ + (0, 1, 2, 3), (4, 5, 6, 7), (0, 1, 5, 4), + (1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7) + ] + + mesh.from_pydata(verts, [], faces) + mesh.update() + + # Place origin at Back-Left (Min X, Max Y, Min Z) + obj.location = (bounds['min'].x, bounds['max'].y, bounds['min'].z) + return obj + +if __name__ == "__main__": + print("Phase 2: Geometry (Origin Alignment) logic loaded.") \ No newline at end of file diff --git a/scripts/phase3_operator.py b/scripts/phase3_operator.py new file mode 100644 index 0000000..4717688 --- /dev/null +++ b/scripts/phase3_operator.py @@ -0,0 +1,59 @@ +import bpy + +def setup_stocker_operator(logic_module): + """ + Wraps the phase 1 & 2 logic into a Blender Operator. + """ + class STOCKER_OT_setup(bpy.types.Operator): + bl_idname = "object.stocker_setup" + bl_label = "Stocker Helper" + bl_description = "Generate a boundary cube for the selected objects" + bl_options = {'REGISTER', 'UNDO'} + + mode: bpy.props.EnumProperty( + name="Packing Mode", + description="Select the geometry nodes template to apply", + items=[ + ('GRID', "Grid Pack", "Use the Grid Pack template"), + ('CIRCLE', "Circle Pack", "Use the Circle Pack template"), + # Placeholder for future modes + ], + default='GRID' + ) + + def execute(self, context): + # 1. Get selected objects + selected_objs = context.selected_objects + + # 2. Run logic from previous phases + bounds = logic_module.calculate_global_bounds(selected_objs) + if not bounds: + self.report({'ERROR'}, "No valid mesh objects selected.") + return {'CANCELLED'} + + # 3. Create the cube + new_obj = logic_module.create_aligned_boundary_cube(bounds) + if not new_obj: + self.report({'ERROR'}, "Failed to create boundary cube.") + return {'CANCELLED'} + + # 4. Modifier Injection + # Search for the node group + group_name = "grid_pack" if self.mode == 'GRID' else "Stocker_Circle" + node_group = bpy.data.node_groups.get(group_name) + + if not node_group: + self.report({'WARNING'}, f"Node Group '{group_name}' not found. Boundary created without modifier.") + return {'FINISHED'} + + # Add Geometry Nodes modifier + mod = new_obj.modifiers.new(name="circle_pack", type='NODES') + mod.node_group = node_group + + self.report({'INFO'}, f"Stocker setup complete using {self.mode} mode.") + return {'FINISHED'} + + return STOCKER_OT_setup + +if __name__ == "__main__": + print("Phase 3: Integration (The Operator) logic loaded.")