Align viewport rotation to normal vector like Shift+7 in edit mode

I know this question has been asked and answered before but the behavior I want is what ⇧ Shift + Num 7 exactly does in edit mode which is to align the viewport to "face" the element selected (in my case a face or polygon) and "rotate" the viewport on its axis to match the orientation of the normal.

My code takes care of the first part which is to position the viewport to "face" the polygon and have no problem orienting itself on their normals except for faces that are rotated in multiple axis. e.g. the active face in the picture below, for the other faces which are angled only on a single axis the script has no problem orienting itself on their normals.

In my original code, the variables are delivered by raycast so I'd like to achieve this effect using only hit, normal plus the viewport and object properties. Face index is avoided since the target objects are using unapplied modifiers.

import bpy
import bmesh
from mathutils import *; from math import *

for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
rv3d = area.spaces[0].region_3d

obj = bpy.context.edit_object
mat = obj.matrix_world
me = obj.data

bm = bmesh.from_edit_mesh(me)
f = bm.select_history.active

start = f.calc_center_median()
normal = f.normal

stop  = start + normal
norm = start - stop
norm.normalize()

q = norm.to_track_quat('-Z', 'Y')
rv3d.view_rotation = q
rv3d.view_location = start

bmesh.update_edit_mesh(me, True)


rotateview_blend_file

I solved it. If the tangent picks the wrong direction and doesn't align to the viewport, the rotation matrix inverts and uses the negative value of the angle for correction. Keeping it here for prosperity and to help those seeking this kind of alignment behavior.

import bpy
import bmesh
from mathutils import *; from math import *

for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
rv3d = area.spaces[0].region_3d

obj = bpy.context.active_object
mat = obj.matrix_world
me = obj.data

bm = bmesh.from_edit_mesh(me)
f = bm.select_history.active

up = Vector((0,0,1))
up1 = Vector((0,-1,0))

center = f.calc_center_median()
normal = f.normal
tangent = f.calc_tangent_edge()

mw = mat.to_3x3()
quat = normal.rotation_difference(mw.inverted() @ up)
rot_mat = mw @ quat.to_matrix()

loc, rot, scale = rv3d.view_matrix.decompose()

mat_scale = Matrix()
for i in range(3):
mat_scale[i][i] = scale[i]

rv3d.view_matrix = Matrix.Translation(loc) \
@ rot_mat.to_4x4() \
@ mat_scale

vrot = rv3d.view_rotation
rot_axis = vrot @ up
rot_dir = vrot @ up1

rotdiff = rot_dir.rotation_difference(tangent)
M = mw @ Matrix.Rotation(rotdiff.angle, 3, rot_axis)
rv3d.view_rotation.rotate(M)

if (rv3d.view_rotation @ up1).rotation_difference(tangent).angle != 0:
rv3d.view_rotation.rotate(M.inverted())
M = mw @ Matrix.Rotation(-rotdiff.angle, 3, rot_axis)
rv3d.view_rotation.rotate(M)

rv3d.view_location = obj.matrix_world @ center
bmesh.update_edit_mesh(me, True)


I've partly figured it out. The earlier code and this still has issues with unapplied rotation but that's another problem. There are two rotating mechanism: the first one aligns the viewport to the face by modifying the view matrix, the second one rotates the view rotation itself to match the normal alignment of the face. I ended up using the face's tangent information in the end.

import bpy
import bmesh
from mathutils import *; from math import *

for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
rv3d = area.spaces[0].region_3d

obj = bpy.context.edit_object
mat = obj.matrix_world
me = obj.data

bm = bmesh.from_edit_mesh(me)
f = bm.select_history.active

center = f.calc_center_median()
normal = f.normal
tangent = f.calc_tangent_edge()

up = Vector((0,0,1))
up1 = Vector((0,-1,0))

mw = obj.matrix_world.to_3x3()
mr = normal.rotation_difference(up)
mat = mw @ mr.to_matrix()

loc, rot, scale = rv3d.view_matrix.decompose()

mat_scale = Matrix()
for i in range(3):
mat_scale[i][i] = scale[i]

rv3d.view_matrix = Matrix.Translation(rv3d.view_matrix.inverted().translation) \
@ mat.to_4x4() \
@ mat_scale \

vrot = rv3d.view_rotation
rotdiff = (vrot @ up1).rotation_difference(tangent)
M = Matrix.Rotation(rotdiff.angle, 4, vrot @ up)
rv3d.view_rotation.rotate(M)

rv3d.view_location = obj.matrix_world @ center
bmesh.update_edit_mesh(me, True)


If you test this on faces in edit mode (single active selection only), it works on most of them but for some that fails, using "-rotdiff.angle" or "M.inverted()" seem to do the trick although I haven't figured out how to trigger this for those faces. It's so close.