Inserting an example from the menu also declares the parameters it needs, so a freshly inserted snippet compiles as-is. Pasting one from this page does not - declare the parameters it names (Mask, Reference, Iterations) in the pin editor first.
Expression examples
These are the expression half: paste one into the Code field as it is.
Deform
Inflate Along Normal (Masked)
Push vertices outward along their normal, scaled by the Mask input. Run Over: Vertex. Insert already declared the classic Mask; wire a weight map into the Mask parameter.
float amount = 2.0 * mask;
P += normalize(N) * amount;
Smooth Only The Masked Region
Blend a whole-mesh relax smooth against the original position, gated by the Mask input. Run Over: Vertex. Insert already declared the classic Mask; wire a weight map into the Mask parameter.
vector smoothed = relax(4);
P = lerp(P, smoothed, mask);
Iterative Smooth (Iterations)
Average each vertex toward its one-ring neighbours - a single pass only nudges the mesh once. Run Over: Vertex. Insert already set Iterations to 5 on this node, so this pass actually runs five times, each one reading the previous pass's result - that is what turns the one-step average below into a real smooth. Compare to relax(n), which is a pre-pass that runs once, before your code, not a repeat of your code itself.
Insert also sets Iterations.
vector sum = vec3(0, 0, 0);
float count = 0;
foreach (nb in neighbours(ptnum)) { sum += pointpos(nb); count += 1; }
if (count > 0) { P = sum / count; }
Noise Displacement (Seeded)
Push each vertex by a seeded 3D noise field - change the seed for a different look without touching anything else. Run Over: Vertex.
P += vnoise(P * 0.05, 7) * 2;
Taper Along Z
Taper the mesh toward a point along Z: full width at the base, narrowed at the top. Run Over: Vertex.
float t = fit(P.z, bboxmin().z, bboxmax().z, 1, 0.3);
P.x *= t;
P.y *= t;
Distance-To-Reference Falloff
Fade toward 1 near the Reference input's surface and toward 0 far from it. Run Over: Vertex. Wire the classic Reference parameter. If you already renamed this node's Weight Map output, write wmap("YourName") = ... instead of "Weights".
float d = neardist("Reference", P);
wmap("Weights") = 1 - saturate(d / 25);
Project Onto Reference Surface
Pull vertices onto the nearest point on the mesh wired into the Reference parameter, blended by the Mask input. Run Over: Vertex. Wire the classic Reference parameter and a weight map into the Mask parameter.
vector target = nearpoint("Reference", P);
P = lerp(P, target, mask);
Blend Toward Index-Matched Reference
Blend toward the mesh wired into the Reference parameter when it shares this mesh's exact vertex order (a blend-shape target). Run Over: Vertex. Wire the classic Reference parameter. Needs a Float Parameter named "blend".
vector donorPos = pointpos("Reference", ptnum);
P = lerp(P, donorPos, ch("blend"));
Attributes
Transfer Attribute From A Second Mesh (Named Pin)
Sample a tagged attribute at the nearest surface point on a second mesh - beyond the Reference parameter - and paint it as greyscale colour. Run Over: Vertex. Needs a Parameter of type Mesh named "Donor", tagged with a Float attribute called "thickness".
float thickness = surfattr("Donor", "thickness", P);
Cd = vec3(thickness, thickness, thickness);
Skinning
Visualize Bone Weight Glob
Visualize the combined skin weight of every bone whose name matches a glob, as greyscale vertex colour. Run Over: Vertex. The subject mesh needs a skeleton with bone names matching thigh_*.
float w = weight("thigh_*");
Cd = vec3(w, w, w);
Harden A Skin Weight
Push a bone's influence to full strength wherever it already dominates; setboneweight renormalizes on apply. Run Over: Vertex. The subject mesh needs a skeleton with a bone named spine_02. Needs a Float Parameter named "threshold".
if (weight("spine_02") > ch("threshold")) { setboneweight("spine_02", 1); }
Color & UV
UV Band Mask
Repeating stripes across U, written to the node's Weights output as a paintable mask. Run Over: Vertex. If you already renamed this node's Weight Map output, write wmap("YourName") = ... instead of "Weights".
wmap("Weights") = sin(uv.x * pi * 8) * 0.5 + 0.5;
Vertex Colour From Weight Map
Paint a WeightMap parameter's values as greyscale vertex colour - handy for sanity-checking a mask. Run Over: Vertex. Needs a Parameter of type Weight Map named "Falloff" wired.
float w = wmap("Falloff");
Cd = vec3(w, w, w);
Selection
Boundary Ring Detect
Colour boundary vertices red and interior vertices white, to spot open edges before a boolean or a bake. Run Over: Vertex.
if (isboundary()) { Cd = vec3(1, 0, 0); } else { Cd = vec3(1, 1, 1); }
Detail Mode
Center Mesh On Origin
Recenter the whole mesh on the origin by subtracting its centroid from every vertex. Run Over: Once (Detail). Insert already declared the classic Mesh; wire a mesh into it.
Insert also sets the Run Mode the snippet needs.
vector c = centroid();
foreach (i in points()) { setpointpos(i, pointpos(i) - c); }
Triangle Mode
Area-Based Mask
Flag small triangles for cleanup by writing a 0..1 mask attribute from triangle area. Run Over: Triangle. Needs Create Missing Attributes on to let @areaMask be created.
Insert also sets the Domain the snippet needs.
@areaMask = fit(area, 0, 50, 0, 1);
Python examples
These are the Python half. A snippet that already opens with #@python is a whole document - paste it as it is; the rest are script bodies, so put them inside a #@python block.
Mixed
Mixed: Python feeds the expression
#@python
#@requires numpy
# Compute the bounding-box centre and longest axis once, in Python,
# then let the expression below use them per vertex.
if mm.num_points == 0:
mm.note("add a Mesh parameter first")
mm.set_detail("centre", [0.0, 0.0, 0.0])
mm.set_detail("longest", 0.0)
else:
import numpy as np
p = np.frombuffer(mm.P, dtype=np.float64).reshape(-1, 3)
lo, hi = p.min(axis=0), p.max(axis=0)
mm.set_detail("centre", ((lo + hi) * 0.5).tolist())
mm.set_detail("longest", float((hi - lo).max()))
#@wrangle
// Push every vertex away from the centre, scaled by the mesh's own size.
vector c = pyv("centre");
float s = py("longest");
P += normalize(P - c) * (s * 0.02);
Interactive
Tkinter Slider + Apply
Insert also declares amount (Float) and ticks Interactive Script, switching this node to an isolated interpreter.
#@python
# Tkinter Slider + Apply: a script that opens ITS OWN window and waits for the
# artist to move a slider and press Apply.
# REQUIRES: Interactive Script and an isolated interpreter (this example ticks
# both on insert). In-process is refused outright (a GUI event loop would hang
# the whole app unkillably).
import tkinter as tk
amount = [float(mm.params().get("amount", 0.0))]
root = tk.Tk()
root.title("Wrangle: Push Along Normal")
# The child spawns with a HIDDEN startup window (SW_HIDE-style startup info) -
# these three lines make that moot, forcing the window to the front the moment
# it exists.
root.deiconify()
root.lift()
root.attributes("-topmost", True)
def on_apply():
amount[0] = slider.get()
root.destroy()
slider = tk.Scale(root, from_=-10.0, to=10.0, resolution=0.1, orient=tk.HORIZONTAL, label="Amount")
slider.set(amount[0])
slider.pack(padx=12, pady=12)
tk.Button(root, text="Apply", command=on_apply).pack(pady=(0, 12))
def heartbeat():
# A progress heartbeat, not a cancel check - Cancel is a hard kill in
# isolated mode, handled entirely on the C++ side; this call only keeps
# the dialog's own progress label alive while the window is open.
mm.progress(0.5, "waiting for the window...")
root.after(200, heartbeat)
root.after(200, heartbeat)
root.mainloop()
mm.set_detail("amount", amount[0])
mm.note(f"amount = {amount[0]:.2f}")
#@wrangle
// Push along the normal by the slider amount the Python window wrote.
P += normalize(N) * py("amount");
Tkinter Hosted UI
Insert also declares amount (Float) and ticks Interactive Script, switching this node to an isolated interpreter.
#@python
# Tkinter hosted by mm.ui.host(where='panel') - stdlib only.
# REQUIRES: an isolated interpreter. mm.ui.host implies Interactive Script
# and isolation even if those tickboxes are off (this example still ticks both).
# where='panel' embeds above the code editor (Windows). where='window' floats.
# tk.Tk() auto-attaches after host(where='panel'); mm.ui.attach(root) also works.
import tkinter as tk
amount = [float(mm.params().get("amount", 0.0))]
mm.ui.host(where='panel', height=220)
root = tk.Tk()
root.title("Wrangle: Push Along Normal")
def on_apply():
amount[0] = slider.get()
root.destroy()
slider = tk.Scale(root, from_=-10.0, to=10.0, resolution=0.1, orient=tk.HORIZONTAL, label="Amount")
slider.set(amount[0])
slider.pack(fill='both', expand=True, padx=8, pady=8)
tk.Button(root, text="Apply", command=on_apply).pack(pady=(0, 8))
root.mainloop()
mm.set_detail("amount", amount[0])
mm.note(f"amount = {amount[0]:.2f}")
#@wrangle
// Push along the normal by the slider amount the Python window wrote.
P += normalize(N) * py("amount");
Python Sculpt Lab
Insert also ticks Interactive Script, switching this node to an isolated interpreter.
#@python
#@requires numpy
# Python Sculpt Lab: a window with its OWN interactive 3D viewport and
# custom brushes that are NOT in the core Mesh Morpher toolset
# (Snake Hook, Crease, Blob, Nudge, Vortex, Polish, Magnet, Wrinkle).
# REQUIRES: Interactive Script and an isolated interpreter (this example
# ticks both on insert). LMB sculpts; Alt-LMB orbits; Apply writes mm.set_P().
import os
import runpy
def _sculpt_lab_candidates():
out = []
try:
root = os.path.normpath(os.path.join(mm.cache_dir, '..', '..', '..'))
out.append(os.path.join(root, 'Tools', 'python', '_sculpt_lab.py'))
out.append(os.path.join(root, 'Plugins', 'MeshMorpherGraph', 'Resources', 'Python', 'sculpt_lab.py'))
except Exception:
pass
out.append(os.path.abspath('_sculpt_lab.py'))
return out
path = None
for c in _sculpt_lab_candidates():
if c and os.path.isfile(c):
path = c
break
if not path:
raise mm.Error('Python Sculpt Lab not found (Tools/python/_sculpt_lab.py)')
mm.note('sculpt lab: ' + path)
runpy.run_path(path, init_globals={'mm': mm}, run_name='__sculpt_lab__')
#@wrangle
No Dependencies
Height Ramp
Height Ramp: normalize Z height into a 0..1 vertex attribute. mm.P is (N,3) when numpy is importable, else a flat float64 view.
p = mm.P
n = mm.num_points
def height(i):
return float(p[i][2] if getattr(p, "ndim", 1) == 2 else p[i * 3 + 2])
buf = bytearray(n * 8)
view = memoryview(buf).cast('d')
zmin = min(height(i) for i in range(n)) if n else 0.0
zmax = max(height(i) for i in range(n)) if n else 0.0
span = (zmax - zmin) or 1.0
for i in range(n):
view[i] = (height(i) - zmin) / span
mm.set_attrib("heightRamp", buf, "vertex", "float")
mm.note(f"height range {zmin:.1f} .. {zmax:.1f}")
CSV Import
CSV Import: read one value per vertex from a CSV file in this node's cache folder.
import csv
import os
path = os.path.join(mm.cache_dir, "values.csv")
n = mm.num_points
if not os.path.exists(path):
mm.note(f"no values.csv in {mm.cache_dir} - using 0 everywhere")
values = [0.0] * n
else:
with open(path, newline='') as f:
values = [float(row[0]) for row in csv.reader(f) if row]
values = (values + [0.0] * n)[:n]
buf = bytearray(n * 8)
view = memoryview(buf).cast('d')
for i, v in enumerate(values):
view[i] = v
mm.set_attrib("csvValue", buf, "vertex", "float")
Fetch Once
Fetch Once: download a reference file once and reuse it. The script itself runs every Run, but the os.path.exists guard below skips the actual download whenever the file is already sitting in mm.cache_dir, so a re-run costs almost nothing once it has fetched the file the first time.
Insert also leaves the Mesh parameter undeclared - the snippet does not need one.
import os
import urllib.request
path = os.path.join(mm.cache_dir, "reference.json")
if not os.path.exists(path):
mm.note("downloading reference.json (first Run only)")
urllib.request.urlretrieve("https://example.com/reference.json", path)
else:
mm.note("using cached reference.json")
mm.set_detail("fetched_path", path)
Ramp + Transform
Ramp + Transform: sample a Ramp parameter named "Falloff" by height, then apply a Transform parameter named "Offset" to the result. Needs a Ramp parameter "Falloff" and a Transform parameter "Offset" declared on this node. mm.P is (N,3) when numpy is importable, else a flat float64 view.
p = mm.P
n = mm.num_points
ramp = mm.param("Falloff")
xf = mm.param("Offset")
tmp = memoryview(bytearray(n * 3 * 8)).cast('d')
def xyz(i):
if getattr(p, "ndim", 1) == 2:
return float(p[i][0]), float(p[i][1]), float(p[i][2])
return float(p[i * 3]), float(p[i * 3 + 1]), float(p[i * 3 + 2])
for i in range(n):
x, y, z = xyz(i)
t = ramp(max(0.0, min(1.0, z / 100.0)))
tmp[i * 3] = x
tmp[i * 3 + 1] = y
tmp[i * 3 + 2] = z + t * 5.0
mm.set_P(xf.transform_points(tmp))
Python-only Node
Python-only Node: this document is nothing but a #@python block - there is no #@wrangle section at all, so there is no expression to compile; this script alone is the whole node. Legal exactly the way an empty document (no python either) is. mm.P is (N,3) when numpy is importable, else a flat float64 view.
p = mm.P
n = mm.num_points
def x_of(i):
return float(p[i][0] if getattr(p, "ndim", 1) == 2 else p[i * 3])
buf = bytearray(n * 8)
view = memoryview(buf).cast('d')
cx = (sum(x_of(i) for i in range(n)) / n) if n else 0.0
for i in range(n):
view[i] = abs(x_of(i) - cx)
mm.set_attrib("distFromCenterX", buf, "vertex", "float")
mm.note("Python-only: no expression needed")
Button: Bake To Attribute
Button: Bake To Attribute. Declares a button every run (mm.ui.button); an ordinary Run press only shows a preview count, but pressing the button itself (mm.event == "button:Bake") writes the count into a real attribute.
mm.ui.button("Bake", "Write the vertex count into @baked")
n = mm.num_points
if mm.event == "button:Bake":
buf = bytearray(n * 8)
view = memoryview(buf).cast('d')
for i in range(n):
view[i] = float(i)
mm.set_attrib("baked", buf, "vertex", "float")
mm.note(f"baked {n} value(s)")
else:
mm.note(f"press Bake to write {n} value(s) into @baked")
Needs A Package
Curvature Attribute (numpy)
#@requires numpy
# Curvature Attribute (numpy): mean curvature proxy from vertex-normal disagreement
# across every edge. The '#@requires numpy' line above installs it into the managed
# folder automatically on next Run.
if mm.num_points == 0:
mm.note("add a Mesh parameter first")
else:
import numpy as np
n_pts = mm.num_points
p = np.frombuffer(mm.P, dtype=np.float64).reshape(-1, 3)
nrm = np.frombuffer(mm.N, dtype=np.float32).reshape(-1, 3).astype(np.float64)
tris = np.frombuffer(mm.tris, dtype=np.int32).reshape(-1, 3)
accum = np.zeros(n_pts)
counts = np.zeros(n_pts)
for a, b, c in tris:
for i, j in ((a, b), (b, c), (c, a)):
d = 1.0 - float(np.dot(nrm[i], nrm[j]))
accum[i] += d; accum[j] += d
counts[i] += 1; counts[j] += 1
curv = np.divide(accum, counts, out=np.zeros_like(accum), where=counts > 0)
mm.set_attrib("curvature", curv.astype(np.float64).tobytes(), "vertex", "float")
KD-Tree Donor Distance (scipy)
Insert also declares Donor (Mesh) and turns Create Missing Attributes on.
#@python
#@requires scipy
# KD-Tree Donor Distance (scipy): distance from each vertex to the nearest point on a
# donor Mesh parameter named "Donor". The '#@requires scipy' line above installs it
# into the managed folder automatically on next Run. Needs a Parameter of type Mesh
# named "Donor" wired.
if mm.num_points == 0:
mm.note("add a Mesh parameter first")
mm.set_detail("gap_max", 0.0)
else:
from scipy.spatial import cKDTree
import numpy as np
donor = mm.param("Donor")
donor_p = np.frombuffer(donor.P, dtype=np.float64).reshape(-1, 3)
p = np.frombuffer(mm.P, dtype=np.float64).reshape(-1, 3)
tree = cKDTree(donor_p)
dist, _ = tree.query(p)
mm.set_attrib("gap", dist.astype(np.float64).tobytes(), "vertex", "float")
mm.set_detail("gap_max", float(dist.max()) if len(dist) else 0.0)
#@wrangle
// Distance to the Donor mesh as a 0..1 weight (1 = on the donor). If you already renamed this node's Weight Map output, write wmap("YourName") = ... instead of "Weights".
wmap("Weights") = 1 - saturate(@gap / py("gap_max"));