# Align & Scale Light (Portal) to Face

I am an ArchViz artist and during my work I often have to do this tedious task manually: Setting up light portals to align exactly with the window pane mesh. First I have to snap it to the window pane, then adjust the size of the portal. For bigger projects, where I have multiple storeys with many windows, this is quite a tedious task, especially if the windows have different sizes!

A few years ago, I was using E-Cycles (a cycles fork) that had this great operator: Face to Portal. Select a face, right click, click the face to portal operator and done. It created and aligned a portal light exactly to the measurements of the selected face.

I don't need it to be an operator necessarily, but does anyone have an idea how I could automate this task?

Thanks a lot for anyone's help!

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)
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'}

self.layout.operator(FaceToPortalOperator.bl_idname, text=FaceToPortalOperator.bl_label)

def register():
bpy.utils.register_class(FaceToPortalOperator)
# Usable both in object and edit

def unregister():
bpy.utils.unregister_class(FaceToPortalOperator)

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.

• Wow, amazing! Works perfectly well. Thanks a lot! As a novice regarding python and scripting and addons; is there an easy way to turn this into an addon (so that I don't have to keep the script and run it every time I re-open a file)?
– oeri
Commented Nov 29, 2023 at 15:52
• One more thing - I've tried fiddling around with the script and my basic coding knowledge but couln't make it work: How can I set the created lights as portals (bpy.context.object.data.cycles.is_portal = True)?
– oeri
Commented Nov 29, 2023 at 16:09
• Another version added above for an addon. Commented Nov 29, 2023 at 16:43
• Wow, thank you!
– oeri
Commented Nov 30, 2023 at 11:20