A Python script to do that.
The tricky part (I think) is to make it work whatever the face is: orientation, number of vertices, concavities... I guess that in this kind of cases, we want the light to cover the entire surface.
For normal cases you can select the faces and use the menu:
Here is the commented code (but ask in comments if questions) and the file (v4.0).
Few explanation about the bounding box parts below. If we want it to work whatever the face shape or orientation is, we need to have the convex hull of the face, then search for the boundings that cover a minimal surface.
Having that, we so have the wanted rotation for the light around its Z axis. The face center (the position of the light) is then adjusted to the center of the bounding box.
import bpy, bmesh
from mathutils import Matrix, Vector
from math import atan2
# Create a light object of type 'AREA'
def create_light(name):
light = bpy.data.lights.new(name, 'AREA')
light.shape = 'RECTANGLE'
light.size = light.size_y = 2
light_object = bpy.data.objects.new(name, light)
bpy.context.collection.objects.link(light_object)
return light_object
# Get the boundings coordinates in 2D/XY
def boundings_XY(coordinates):
min_x = min(co.x for co in coordinates)
max_x = max(co.x for co in coordinates)
min_y = min(co.y for co in coordinates)
max_y = max(co.y for co in coordinates)
return min_x, max_x, min_y, max_y
# Search for the good orientation so that the bounding has minimal surface
def calc_min_boundings_XY(coordinates):
# by default, no rotation
min_angle = 0
min_x, max_x, min_y, max_y = boundings_XY(coordinates)
min_area = (max_x - min_x) * (max_y - min_y)
size_x, size_y = max_x - min_x, max_y - min_y
center = Vector(((min_x + max_x) / 2, (min_y + max_y) / 2,0))
# Loop over each edge
co = coordinates[0]
for i in range(1, len(coordinates)):
co_next = coordinates[i]
delta = co_next - co
# Get the angle of this edge and the corresponding rotation (around 0,0 as this is already centered)
angle = atan2(delta.x, delta.y)
rotation = Matrix.Rotation(angle, 4, 'Z')
# Calculate the boundings once rotated
min_x, max_x, min_y, max_y = boundings_XY([rotation @ co for co in coordinates])
# Calculate the area of it
area = (max_x - min_x) * (max_y - min_y)
# If smaller, we keep it
if area < min_area:
min_area = area
min_angle = angle
size_x, size_y = max_x - min_x, max_y - min_y
center = rotation.inverted() @ Vector(((min_x + max_x) / 2, (min_y + max_y) / 2,0))
co = co_next
# inverted angle (for some reason ;))
return -min_angle, size_x, size_y, center
# Extract the convex hull coordinates and center from a face
def get_hull_and_center(bm, face):
hull_result = bmesh.ops.convex_hull(bm, input=face.verts, use_existing_faces=False)
hull_co = [v.co for v in hull_result['geom'] if type(v) is bmesh.types.BMVert]
center = Vector()
for co in hull_co: center += co
return hull_co, center / len(hull_co)
# Create a light from a given face
def create_light_on_face(object, bm, face):
# create the light object
light_object = create_light(f"{object.name}-light-{face.index}")
# get the convex hull coordinates and center
hull_co, center = get_hull_and_center(bm, face)
# come back in world coordinates as the light will be placed in world
matrix_world = object.matrix_world
center = matrix_world @ center
normal = matrix_world.to_3x3() @ face.normal
# the rotation part of the normal considering we want to align the light Z to the normal (in world)
rotation = Vector((0, 0, 1)).rotation_difference(normal).to_matrix()
# Centered face coordinates, aligned to the previous rotation
centered_XY = [rotation.inverted() @ ((matrix_world @ co) - center) for co in hull_co]
# Get the wanted angle (around Z) and size for the light
angle, size_x, size_y, bb_center = calc_min_boundings_XY(centered_XY)
# the translation part to the center (in world)
# The center is adjusted considering the bounding box we found
center += rotation @ bb_center
translation = Matrix.Translation(center)
# This previous angle is a rotation around Z for the light
angle_rotation = Matrix.Rotation(angle, 4, 'Z')
# Align de light: rotated around its Z, rotated to the face normal and translated to the center
light_object.matrix_world = translation @ rotation.to_4x4() @ angle_rotation
# Assign the sizes
light_object.data.size = size_x
light_object.data.size_y = size_y
def create_lights_on_object(object):
if object.mode == 'EDIT':
bm = bmesh.from_edit_mesh(object.data)
else:
bm = bmesh.new()
bm.from_mesh(object.data)
# Make a copy because the mesh is altered by the convex hull
bm_copy = bm.copy()
selected_faces = [f for f in bm_copy.faces if f.select]
for face in selected_faces:
create_light_on_face(object, bm_copy, face)
bm.free()
bm_copy.free()
def main(context):
create_lights_on_object(context.active_object)
class FaceToPortalOperator(bpy.types.Operator):
"""Tooltip"""
bl_idname = "object.face_to_portal"
bl_label = "Face to portal"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
active_obj = context.active_object
return (active_obj is not None) and (active_obj.mode == 'OBJECT' or active_obj.mode == 'EDIT')
def execute(self, context):
main(context)
return {'FINISHED'}
def menu_func(self, context):
self.layout.operator(FaceToPortalOperator.bl_idname, text=FaceToPortalOperator.bl_label)
def register():
bpy.utils.register_class(FaceToPortalOperator)
# Usable both in object and edit
bpy.types.VIEW3D_MT_object.append(menu_func)
bpy.types.VIEW3D_MT_edit_mesh.append(menu_func)
def unregister():
bpy.utils.unregister_class(FaceToPortalOperator)
bpy.types.VIEW3D_MT_object.remove(menu_func)
bpy.types.VIEW3D_MT_edit_mesh.remove(menu_func)
if __name__ == "__main__":
register()
Below a version that can be used as an addon (bl_info, etc.), and with a 'is_portal' prop added at the light (data) level.
To register it, save the code in a file.py file. Then install it from the preferences using this file.