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.