3
$\begingroup$

[ Working code at the bottom, just paste in Blender's text editor. ]

There are similar Bounding Box questions out there but this is different, please don't close.

I made a script which iterates through the bound_box of each selected object to find the smallest and largest world space Vector coordinates, which are used to create a Lattice that is sized to fit all of those objects perfectly (a single huge bounding box).

HOWEVER

What I would actually like is for that Lattice to be aligned to the rotation of the Active Object specifically, but still fit tightly around all the other selected objects. It's not a matter of simply making a huge lattice, the tight fit is important. Example images below:

IMG 1 (what I currently have):

enter image description here

IMG 2 (what I'm hoping to get):

enter image description here

Note:

  • The Lattice has the exact same rotation as the Active Object (in all axis).
  • The lattice represents an accurate bounding box of the selected objects if we pretend the Active Object's rotation is the world's orientation.
  • Average direction the objects are placed in does not matter, only the Active Object's rotation matters.

Anyone have any idea how to accomplish this? If I just rotate the lattice with my current code the dimensions will be wrong because I'm currently calculating the bounds in world space, instead of using the Active objec's rotation as the world orientation.

CODE:

Pastebin for easy copypasta: https://pastebin.com/gFnCCs3d

import bpy
from bpy.props import IntProperty, BoolProperty, FloatVectorProperty

from mathutils import Vector


class ARMORED_OT_lattice(bpy.types.Operator):
    '''
    Creates a lattice that matches the dimensions and transforms of the selected objects.
    '''

    bl_idname = 'object.armored_lattice'
    bl_label  = 'ARMORED Lattice'
    bl_options = {'REGISTER', 'UNDO'}

    resolution: IntProperty(
        name='Resolution', default=2, min=2)

    scale_offset: FloatVectorProperty(
        name='Scale Offset', 
        #step=0.1, 
        description='Makes the lattice larger than the object')
    
    rotate_to_active: BoolProperty(
        name='Rotate to Active', default=False, 
        description='Rotate the Lattice to match the Active Object')
    
    @classmethod
    def poll(cls, context):
        return context.selected_objects
    
    def execute(self, context):
        self.selected = context.selected_objects
        self.active = context.active_object

        if context.mode != 'OBJECT':
            bpy.ops.object.mode_set(mode='OBJECT')

        self.lattice = self._create_lattice()
        self._set_lattice_resolution()
        self._set_lattice_transforms(context)

        return {'FINISHED'}
    

    def _create_lattice(self) -> bpy.types.Lattice:
        data = bpy.data.lattices.new('Lattice')
        lattice = bpy.data.objects.new('Lattice', data)
        bpy.context.collection.objects.link(lattice)

        return lattice

    def _set_lattice_resolution(self):
        data = self.lattice.data
        data.points_w = self.resolution
        data.points_u = self.resolution
        data.points_v = self.resolution
    
    def _set_lattice_transforms(self, context):
        bb_min, bb_max = self._selection_min_max_bounds(context)

        self.lattice.location = self._centroid([bb_min, bb_max])
        self.lattice.dimensions = self._vector_to_dimensions(vec1=bb_min, vec2=bb_max) + Vector(self.scale_offset)

        # Does NOT work well with multiple selections (dimensions will be incorrect).
        if self.active in self.selected and self.rotate_to_active:
            self.lattice.rotation_euler = self.active.rotation_euler

    def _centroid(self, coords: list[Vector]) -> Vector:
        '''Return the average of a list of coordinates.'''
        
        return sum((Vector(vec) for vec in coords), Vector()) / len(coords)
    
    def _vector_to_dimensions(self, vec1: Vector, vec2: Vector) -> Vector:
        '''Returns the world x, y, z dimensions of an imaginary bounding box formed by two 3d coordinates (opposite corners).'''
        
        return Vector((abs(vec1.x - vec2.x), abs(vec1.y - vec2.y), abs(vec1.z - vec2.z)))

    def _selection_min_max_bounds(self, context) -> tuple[Vector, Vector]:
        '''Returns the Min and Max Vector corners (world space) of the imaginary bounding box surrounding the group of selected objects.'''

        bbox_coords = [obj.matrix_world @ Vector(point) for obj in context.selected_objects for point in obj.bound_box]

        return Vector(map(min, *bbox_coords)), Vector(map(max, *bbox_coords))
    

classes = (
    ARMORED_OT_lattice,
)

def register():
    for cls in classes:
        bpy.utils.register_class(cls)
    
def unregister():
    for cls in classes:
        bpy.utils.unregister_class(cls)


if __name__ == "__main__":
    register()

    # test call
    bpy.ops.object.armored_lattice()

$\endgroup$
2
  • $\begingroup$ Here is a algorithm to get smallest Bounding Box: blender.stackexchange.com/questions/261049/… $\endgroup$
    – X Y
    Sep 9, 2022 at 3:13
  • $\begingroup$ Thanks, but I can already do it with 1 object. It's the multiple objects + the rotation of the active object that I can't figure out. I have a feeling it's a simple solution but I'm not good at math. $\endgroup$ Sep 9, 2022 at 5:34

1 Answer 1

2
$\begingroup$

Create a smallest box outside the selected objects and rotation follow the active object

enter image description here

import bpy, bmesh
import numpy as np
from mathutils import Vector

def get_cube_by_selected_objects():
    verts = []
    for obj in bpy.context.selected_objects:
        if obj.type != 'MESH': continue
        verts += [obj.matrix_world @ v.co for v in obj.data.vertices]
    if not verts: return None

    points = np.asarray(verts)
    means = np.mean(points, axis=1)

    cov = np.cov(points, y = None,rowvar = 0,bias = 1)

    v, vect = np.linalg.eig(cov)

    tvect = np.transpose(vect)
    points_r = np.dot(points, np.linalg.inv(tvect))

    co_min = np.min(points_r, axis=0)
    co_max = np.max(points_r, axis=0)

    xmin, xmax = co_min[0], co_max[0]
    ymin, ymax = co_min[1], co_max[1]
    zmin, zmax = co_min[2], co_max[2]

    xdif = (xmax - xmin) * 0.5
    ydif = (ymax - ymin) * 0.5
    zdif = (zmax - zmin) * 0.5

    cx = xmin + xdif
    cy = ymin + ydif
    cz = zmin + zdif

    corners = np.array([
        [cx - xdif, cy - ydif, cz - zdif],
        [cx - xdif, cy + ydif, cz - zdif],
        [cx - xdif, cy + ydif, cz + zdif],
        [cx - xdif, cy - ydif, cz + zdif],
        [cx + xdif, cy + ydif, cz + zdif],
        [cx + xdif, cy + ydif, cz - zdif],
        [cx + xdif, cy - ydif, cz + zdif],
        [cx + xdif, cy - ydif, cz - zdif],
    ])

    corners = np.dot(corners, tvect)
    # center = np.dot([cx, cy, cz], tvect)
    mat = bpy.context.object.matrix_world

    bpy.ops.mesh.primitive_cube_add(enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
    cube = bpy.context.object
    verts = cube.data.vertices
    verts[0].co = corners[0]
    verts[2].co = corners[1]
    verts[6].co = corners[2]
    verts[4].co = corners[3]
    verts[5].co = corners[6]    
    verts[1].co = corners[7]
    verts[3].co = corners[5]
    verts[7].co = corners[4]
    
    for obj in bpy.context.selected_objects:
        obj.select_set(False)
    bpy.context.view_layer.objects.active = obj
    cube.select_set(True)
    bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='MEDIAN')
    cube.display_type = 'WIRE'
    return cube

def copy_rot(tar, source):
    for obj in bpy.context.selected_objects:
        obj.select_set(False)
    tar.select_set(True)
    bpy.context.view_layer.objects.active = tar

    mat = source.matrix_world
    loc, rot, sca = mat.decompose()
    old_mode = tar.rotation_mode
    tar.rotation_mode = "QUATERNION"
    tar.rotation_quaternion = rot.inverted()
    bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
    tar.rotation_quaternion = rot
    tar.rotation_mode = old_mode

def create_lattice_by_cube(cube, del_org=True):
    "TODO"
    # you need find a way to change points position of the lattice

obj = bpy.context.object
cube = get_cube_by_selected_objects()
if cube is None:
    print("fail")
else:
    copy_rot(cube, obj)
    create_lattice_by_cube(cube)

Create a special face normal box outside the selected objects and rotation follow the active object

enter image description here

import bpy
# import numpy as np
from mathutils import Vector

def get_box_by_selected_objects_with_vec_quaternion(q):
    q_invert = q.inverted()
    verts = []
    for obj in bpy.context.selected_objects:
        obj_mat = obj.matrix_world
        if obj.type != 'MESH': continue
        verts += [q_invert @ (obj_mat @ v.co) for v in obj.data.vertices]
    if not verts: return None

    v_z = [co.z for co in verts]
    v_y = [co.y for co in verts]
    v_x = [co.x for co in verts]

    z_max = max(v_z)
    z_min = min(v_z)
    y_max = max(v_y)
    y_min = min(v_y)
    x_max = max(v_x)
    x_min = min(v_x)

    box_verts = (
        (x_min, y_min, z_min),
        (x_min, y_max, z_min),
        (x_max, y_max, z_min),
        (x_max, y_min, z_min),
        (x_max, y_min, z_max),
        (x_min, y_min, z_max),
        (x_min, y_max, z_max),
        (x_max, y_max, z_max),
    )

    box_verts = [q @ Vector(v) for v in box_verts]

    bpy.ops.mesh.primitive_cube_add(size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
    cube = bpy.context.object
    verts = cube.data.vertices

    verts[0].co = box_verts[0]
    verts[2].co = box_verts[1]
    verts[6].co = box_verts[2]
    verts[4].co = box_verts[3]
    verts[5].co = box_verts[4]
    verts[1].co = box_verts[5]
    verts[3].co = box_verts[6]
    verts[7].co = box_verts[7]

    cube.display_type = 'WIRE'
    return cube

def copy_rot(tar, source):
    for obj in bpy.context.selected_objects:
        obj.select_set(False)
    tar.select_set(True)
    bpy.context.view_layer.objects.active = tar

    mat = source.matrix_world
    loc, rot, sca = mat.decompose()
    old_mode = tar.rotation_mode
    tar.rotation_mode = "QUATERNION"
    tar.rotation_quaternion = rot.inverted()
    bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
    tar.rotation_quaternion = rot
    tar.rotation_mode = old_mode

act_obj = bpy.context.object
old_mode = act_obj.rotation_mode
act_obj.rotation_mode = "QUATERNION"

cube = get_box_by_selected_objects_with_vec_quaternion(bpy.context.object.rotation_quaternion)
if cube is not None:
    copy_rot(cube, act_obj)

act_obj.rotation_mode = old_mode
$\endgroup$
3
  • $\begingroup$ Thanks for your knowledge. Your solution is very close but the rotation seems to be based on the average direction the selected objects are placed in, instead of strictly following the Active object's rotation. Here are a couple of screenshots I took with the code you provided so you can see what I mean. Test 1: i.imgur.com/kUW8w2H.jpeg Test 2: i.imgur.com/4LrvXNJ.jpeg $\endgroup$ Sep 10, 2022 at 0:01
  • 1
    $\begingroup$ The rotation of the active object and the box are same. See the updated image in the answer. $\endgroup$
    – X Y
    Sep 10, 2022 at 7:31
  • $\begingroup$ It's actually fine that the first example is technically cheating, because it's still a really cool algorithm that I will probably use at some point. The second code example works exactly as I hoped (not just having the same rotation value but actually being aligned the same way as the Active). I should of been more specific about that and Include better examples. Thank you so much for taking the time to code both of these scripts, you're amazing! And they work great! $\endgroup$ Sep 10, 2022 at 15:19

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .