Extracted from my answer here, here is another scripted option. In contrary
to Blender Dadaist's answer, this method creates a planar quad, by only moving the active vertex. ⇧ Shift
RMB select the three vertices of the quad, which are not supposed to change, then ⇧ Shift
RMB select the vertice of the quad, which you wish to change, marking it as the active vertex.

The script registers an operator and places it in the menu: Mesh > Vertices > Flatten Vertex with the hotkey ⎈ Ctrl⇧ ShiftM.
bl_info = {
"name": "Flatten Vertex Onto Face",
"category": "Object",
}
import bpy
import bmesh
class ObjectFlattenVertex(bpy.types.Operator):
"""Flatten Vertex Onto Face"""
bl_idname = "edit_mesh.flatten_vertex"
bl_label = "Flatten Vertex"
bl_options = {'REGISTER', 'UNDO'}
influence = bpy.props.FloatProperty(name='Influence', subtype='PERCENTAGE', default=1, min=0, max=1)
def execute(self, context):
if (context.object == None):
self.report({'ERROR_INVALID_CONTEXT'}, "This is only possible with vertices of an object.")
return {'CANCELLED'}
me = context.object.data
if me.is_editmode:
bm = bmesh.from_edit_mesh(me)
else:
bm = bmesh.new()
bm.from_mesh(me)
for elem in bm.select_history:
if (isinstance(elem, bmesh.types.BMVert) == False):
self.report({'ERROR_INVALID_INPUT'}, "Select vertices only.")
return {'CANCELLED'}
if len(bm.select_history) != 4:
self.report({'ERROR_INVALID_INPUT'}, "Select exactly four vertices.")
return {'CANCELLED'}
if (bm.select_history.active == None):
self.report({'ERROR_INVALID_INPUT'}, "Require an active vertex.")
return {'CANCELLED'}
active = bm.select_history.active
# get only selected vertices
p1, p2, origin = [v.co for v in bm.select_history if v != active]
a = active.co - origin
# normal
n = (p1 - origin).cross(p2 - origin)
# projected
prj = n.cross(a.cross(n) / n.length) / n.length
active.co = self.influence * prj + (1 - self.influence) * a + origin
if bm.is_wrapped:
bmesh.update_edit_mesh(me, False, False)
else:
bm.to_mesh(me)
me.update()
return {'FINISHED'}
def menu_func(self, context):
self.layout.operator(ObjectFlattenVertex.bl_idname)
addon_keymaps = []
def register():
bpy.utils.register_class(ObjectFlattenVertex)
bpy.types.VIEW3D_MT_edit_mesh_vertices.append(menu_func)
wm = bpy.context.window_manager # keymap
kc = wm.keyconfigs.addon # background mode check
if kc:
km = wm.keyconfigs.addon.keymaps.new(name = "Window",space_type='EMPTY', region_type='WINDOW')
kmi = km.keymap_items.new(ObjectFlattenVertex.bl_idname, type = "M", shift=True, ctrl=True, value = "PRESS")
addon_keymaps.append(km)
def unregister():
for km, kmi in addon_keymaps:
km.keymap_items.remove(kmi)
addon_keymaps.clear()
bpy.utils.unregister_class(ObjectFlattenVertex)
bpy.types.VIEW3D_MT_edit_mesh_vertices.remove(menu_func)
if __name__ == "__main__":
register()
Some math
At # get only selected vertices
the selected three vertice's coordinates (not the active) are stored in p1
, p2
and origin
. The active vertex is stored in active
.
The two directional vectors of the plane are p1 - origin
and p2 - origin
. The "base" of the plane is at origin
.
Thus, the normal vector of the plane is n = (p1 - origin).cross(p2 - origin)
(cross product).
The active vertex's position relativ to the origin is at a = active.co - origin
.
Now, a
has to be projected onto the plane with the normal vector n
. This is explained here.

In case the link goes down, I will excerpt the math in a very condensed form, but it's just projecting the vector a onto the plane.
a1 ... a projected onto the plane
|a| ... magnitude of a
θ ... angle between a and n
d ... directional vector pointing to a (normalized)
d = (a / |a|) × ((b × a)/|(b × a)|)
d = norm(a) × norm(b × a)
l ... magnitude of the projected vector
l = |a| · sin(θ)
a1 = l · d
a1 = |a| · sin(θ) · norm(a) × norm(b × a)
Given: norm(a) × norm(b) = norm(a × b) · sin(θ)
a1 = norm(a) · norm(n) × norm(a) × norm(n)
a1 = n × (a × n / |n|) / |n|
The shift the projected vector a1
by the position of the origin
.