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!

Viewport with a face selected and a light portal next to it


1 Answer 1


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.

enter image description here

For normal cases you can select the faces and use the menu:

enter image description here

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)
        bm = bmesh.new()
    # 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)

def main(context):

class FaceToPortalOperator(bpy.types.Operator):
    bl_idname = "object.face_to_portal"
    bl_label = "Face to portal"
    bl_options = {'REGISTER', 'UNDO'}

    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):
        return {'FINISHED'}

def menu_func(self, context):
    self.layout.operator(FaceToPortalOperator.bl_idname, text=FaceToPortalOperator.bl_label)

def register():
    # Usable both in object and edit

def unregister():

if __name__ == "__main__":

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.

  • 1
    $\begingroup$ 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)? $\endgroup$
    – oeri
    Nov 29, 2023 at 15:52
  • $\begingroup$ 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)? $\endgroup$
    – oeri
    Nov 29, 2023 at 16:09
  • 1
    $\begingroup$ Another version added above for an addon. $\endgroup$
    – lemon
    Nov 29, 2023 at 16:43
  • $\begingroup$ Wow, thank you! $\endgroup$
    – oeri
    Nov 30, 2023 at 11:20

You must log in to answer this question.

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