Docs

Python API

Everything on the mm object, plus the mm.ui buttons and labels a script can declare.

Part of the Wrangle reference. Everything on this page goes in a #@python block in the node's Code field, and runs once per Run - before the expression half compiles.

Syntax What it does Example
mm.num_points -> int Vertex count of the subject mesh. n = mm.num_points
mm.num_prims -> int Triangle count of the subject mesh. t = mm.num_prims
mm.run_over -> str "vertex", "triangle", "corner", "texel" or "detail" - this run's run-over. if mm.run_over == "vertex": ...
mm.P -> ndarray or memoryview numpy ndarray (N,3) float64 when numpy is importable, else a flat memoryview. Lazy and cached on first access; call mm.set_P() to stage a change. Re-materializes after mm.add_point()/mm.remove_point()/mm.remove_tri() this run. p = mm.P; x0 = p[0]
mm.N -> memoryview (float32, xyz per vertex) Vertex normals (mean of split elements), same lazy-snapshot rule as mm.P. n = mm.N
mm.Cd -> memoryview (float32, rgba per vertex) Vertex colour, same lazy-snapshot rule as mm.P. c = mm.Cd
mm.uv -> memoryview (float32, uv per vertex) Primary UV, same lazy-snapshot rule as mm.P. t = mm.uv
mm.mask -> memoryview (float32, one per vertex) The primary WeightMap input parameter's weight (the mask), empty when none is declared and wired. w = mm.mask[0]
mm.tris -> memoryview (int32, 3 per triangle) Vertex ids per triangle corner. a, b, c = mm.tris[0:3]
mm.selection -> list[int] or ndarray Welded vertex ids whose mask weight is > 0.5. numpy int32 array when numpy is importable, else a list[int]. Empty when the node has not filled a selection. ids = mm.selection
mm.attribs() -> list[str] Every tagged attribute name the mesh currently carries. for name in mm.attribs(): print(name)
mm.attrib(name) -> memoryview (float64) Read a tagged attribute's dense per-element values. thick = mm.attrib("thickness")
mm.params() -> dict Every scalar/vector/int/bool/string parameter's current value, by name - Ramp/Transform/Mesh/Texture/Landmarks are excluded (use mm.param() for those). d = mm.params(); print(d["strength"])
set_P / set_N / set_Cd / set_uv mm.set_P(buf) / mm.set_N(buf) / mm.set_Cd(buf) / mm.set_uv(buf) Stage new channel values - float64 (P) or float32 (N/Cd/uv) bytes, num_points*3/3/4/2. mm.set_P(new_positions)
mm.set_wmap(name, buf) Stage a named Weight Map output write - float32 bytes, num_points (Vertex/Corner run-over only). Any declared Weight Map output, Primary or not; wmap("Name") = ... writes the same way from the expression half. mm.set_wmap("Weights", weights)
mm.set_attrib(name, buf, domain='vertex', type=None) Stage a tagged attribute write. buf is float64 bytes; type is inferred from length when omitted. mm.set_attrib("curvature", data, "vertex", "float")
mm.set_mesh(points, tris, normals=None, uvs=None, colors=None, attribs=None, groups=None) Atomic whole-subject replacement - see the Python stage page. mm.set_mesh(points, tris)
mm.add_point(p) -> int Append a point to the incremental topology working copy; returns its new virtual id - see Python topology. i = mm.add_point((0, 0, 1))
mm.add_tri(a, b, c) -> int Append a triangle over three point ids; returns its new virtual id. t = mm.add_tri(a, b, c)
mm.remove_tri(t, keep_points=False) -> bool Remove triangle t from the working copy. mm.remove_tri(t)
mm.remove_point(i) -> bool Remove point i and every triangle still touching it. mm.remove_point(i)
is_point / is_tri mm.is_point(i) -> bool / mm.is_tri(t) -> bool True if i/t currently names a live element in the topology working copy. if mm.is_point(i): ...
max_points / max_prims mm.max_points() -> int / mm.max_prims() -> int The current point/triangle id ceiling - every id below this has existed this run, whether or not it is still live. n = mm.max_points()
mm.builder(from_subject=False) -> Builder A plain accumulator (.add_point/.add_tri/.remove_tri/.commit(...)) ending in one set_mesh() call - see Python topology. b = mm.builder(); b.add_point((0,0,0)); b.commit()
mm.set_texture(name, path_or_pixels, width=None, height=None, srgb=True, normal=False) Stage a Texture User Output write from an on-disk image path, or from packed RGBA8 pixels (bytes/numpy uint8) with width and height. Creates the named output pin if missing. Any Run Over. mm.set_texture("BaseColor", path)
mm.draw_points(points, color=(1,0.4,0.1), size=4) Stage debug points for the node to draw after this run. points is (N,3) or a list of xyz. Capped at 8192 primitives total; extra calls note the cap and drop. mm.draw_points([(0,0,0)])
mm.draw_lines(pairs, color=(0.2,0.8,1), thickness=1) Stage debug line segments. pairs is a list of (a, b) xyz. Same 8192 primitive cap as draw_points. mm.draw_lines([((0,0,0),(1,0,0))])
mm.draw_vectors(origins, vectors, color=(1,1,0)) Stage debug vectors. origins/vectors are (N,3). Dir is the vector, not an endpoint. Same 8192 primitive cap as draw_points. mm.draw_vectors([(0,0,0)], [(0,0,1)])
mm.bone_names() -> list[str] Skeleton bone names on the subject mesh. Empty list when the mesh has no skeleton. names = mm.bone_names()
mm.bone_weights() -> ndarray or list Dense skin weights as an (N, B) float32 ndarray when numpy is importable, else nested lists. w = mm.bone_weights()
mm.set_bone_weights(buf) Stage a dense (N, B) float32 skin-weight buffer, same row-major layout as mm.bone_weights(). mm.set_bone_weights(w)
mm.set_bone_weight(vertex, bone, weight) Set one skin weight. bone is an int index or a bone name string. Raises mm.Error("mesh has no skeleton") when BoneNames is empty. mm.set_bone_weight(0, "root", 1.0)
mm.polygroups() -> list[int] or ndarray Per-triangle polygroup ids (int32). Empty when the mesh carries none. g = mm.polygroups()
mm.material_ids() -> list[int] or ndarray Per-triangle material ids (int32). Empty when the mesh carries none. m = mm.material_ids()
mm.set_polygroups(buf) Stage per-triangle polygroup ids - int32, one per triangle, on the live subject (no set_mesh required). mm.set_polygroups(g)
mm.set_material_ids(buf) Stage per-triangle material ids - int32, one per triangle, on the live subject (no set_mesh required). mm.set_material_ids(m)
mm.set_landmarks(name, pairs) Stage a Landmarks User Output write. pairs is an iterable of (src, dst) 3-number sequences. Creates the named output pin if missing. Any Run Over. mm.set_landmarks("Landmarks", [((0,0,0),(1,0,0))])
mm.set_deltas(name, deltas) Stage a Delta User Output write. deltas is a dict {int: (x,y,z)} or an iterable of (id, (x,y,z)). Creates the named output pin if missing. Any Run Over. mm.set_deltas("Deltas", {0: (0,0,1), 4: (0,1,0)})
mm.set_transform(name, location=(0,0,0), rotation_euler=(0,0,0), scale=(1,1,1)) Stage a Transform User Output write. rotation_euler is degrees XYZ. Creates the named output pin if missing. Any Run Over. mm.set_transform("Xform", location=(0,0,10))
mm.set_detail(key, value) Stage a whole-run detail value (scalar, 3-tuple/list, or string) - read back via py()/pyv() above. mm.set_detail("scale", 1.25)
note / log mm.note(msg) / mm.log(msg) note() appends a short line to this run's Summary (capped ~8). log() writes a verbose line to the application log only. mm.note(f"wrote {n} values")
mm.progress(t, label='') Report fractional progress (0..1); raises KeyboardInterrupt if the run was cancelled from its progress bar. mm.progress(i / n, "baking")
mm.event -> str "" on an ordinary Run press, or "button:" for the one run a mm.ui.button() press triggered - see the Python UI section below. if mm.event == "button:Bake": ...
mm.prior_detail(key, default=None) -> value Read a detail value this node's own previous successful run staged via mm.set_detail - the read side of a state channel that survives across presses (a button's handler reading what an earlier press already decided). Returns default when key was never set by a prior run. count = mm.prior_detail("presses", 0) + 1
mm.cache_dir -> str Per-node scratch folder under Saved/WrangleScripts - the right place to cache a downloaded file, guarded with an os.path.exists check, so a later Run does not re-fetch it every time (see the code editor's Fetch Once example). path = os.path.join(mm.cache_dir, "data.json")
mm.stats() -> dict {elapsed_ms, notes_so_far} always; plus bbox_min/bbox_max/centroid/surface_area/volume - the same values the expression's own bboxmin()/centroid()/surfacearea()/volume() detail constants expose - when the subject mesh has any vertices. area = mm.stats().get("surface_area", 0.0)
mm.set_camera(preset=None, orbit_yaw=None, orbit_pitch=None, dolly=None, frame_mesh=False, view="source") -> dict Move the preview camera. preset is an absolute framed view of the mesh; orbit_yaw/orbit_pitch (degrees) and dolly (world units) are relative nudges; frame_mesh refits at the current angle. Raises mm.Error when there is no scene or a preset has no mesh bounds. mm.set_camera(preset="front")
mm.get_camera(view="source") -> dict Current camera location/rotation/fov/ortho/ortho_width. cam = mm.get_camera()
mm.set_view_options(view_mode=None, shadows=None, grid=None, floor=None, light_intensity=None, wireframe_overlay=None, view=None) -> dict Restyle how the viewport renders. All args optional - call with none to read. view_mode is "lit"/"unlit"/"wireframe". Styles both panes unless view is given. mm.set_view_options(view_mode="unlit", shadows=False)
mm.get_view_options() -> dict Current view_mode/shadows/grid/floor/light_intensity/wireframe_overlay. opt = mm.get_view_options()
mm.frame_camera(target="mesh", view="source", margin=1.4, center=None, radius=None) -> dict Center and fit the camera on the preview mesh, or target="point" with center=(x,y,z) and radius. mm.frame_camera()
mm.capture_viewport(path=None, view="source", view_mode=None, max_size=None) -> dict Write an offscreen PNG/JPEG of the preview. path defaults to mm.cache_dir/viewport.png. Raises mm.Error when there is no GPU, no scene, or the write failed. mm.capture_viewport()
mm.Error Raise mm.Error("message") for a script-authored failure - reported the same way an uncaught Python exception is. raise mm.Error("missing parameter")

mm.param(name) -> value

Read one declared parameter by name. WeightMap -> bytes; Delta -> {vertex_id: (x,y,z)}; Ramp -> a callable Ramp with .samples (the same baked 256-entry LUT chramp() reads)/.keys/r(t); Transform -> a Transform with .location/.rotation_euler/.scale/.matrix (16 doubles, column-major, matching chm())/.transform_points(buf); Mesh -> a lazy MeshView with .P/.tris/.num_points/.num_prims; Texture -> a TextureView with .width/.height/.row(y)/.pixel(x,y), decoded the same way texture()/texturea() read pixels; Landmarks -> [(src_xyz, dst_xyz), ...].

r = mm.param("falloff"); y = r(0.5)

mm.dna(name=None) -> DNA

Read/write one DNA input parameter by name (name is optional when the node has exactly one). mm.dna.create(name) makes a brand-new DNA from nothing instead, published on output pin name (always an output, even with no input to pair it with) - build it up with set_/clear() the same as any other DNA, but reads are refused ("this DNA does not exist yet") and the whole run is refused if the DNA you built is not structurally sound by the time it commits; mm.dna.created() lists every name ever passed to create() this run. 14 read methods (descriptor/definition/lod/behavior/joint_group/mesh/skin_weights/blendshape_target/ml/rbf/twist_swing/joint_metadata/raw_bytes, input DNA only) plus two kinds of write: staged set_/clear() field edits (applied to the DNA output pin of the same name only after this run finishes without raising; works on input and created DNA alike), and structural DNACalib edits (remove_joint/remove_mesh/remove_blendshape/remove_animated_map/remove_joint_animation/rename_joint/rename_mesh/rename_blendshape/rename_animated_map/prune_blendshape_targets/clear_blendshapes/set_lods/calculate_mesh_lower_lods/rotate/translate/scale/convert_units, input DNA only) that run immediately, in call order, and can add/remove/renumber joints, meshes, blend shapes, animated maps and LODs, correctly renumbering every layer (a section this build does not recognise still survives, but its own internal indices cannot be rewritten). Additive edits (also eager, input and created DNA alike, never touch DNACalib and never renumber joints/meshes) round out a third kind: add_gui_control/add_raw_control/add_expression/add_blendshape_channel/add_animated_map/add_psd/add_rbf_pose_control/add_rbf_pose/add_blendshape_target/add_corrective_blendshape/add_twist/add_swing/add_joint/add_mesh/add_rbf_solver/add_neural_network/add_ml_control/add_lod, plus wire_expression_to_joint_group/blendshape_channel/animated_map/rbf_solver to attach an already-added expression downstream (wire_expression_to_joint_group's attributes= can also insert brand-new joint-attribute rows, placed adjacent to the joint's own first existing row of the same class and inheriting its LOD visibility) - add_raw_control/add_psd/add_expression/add_ml_control shift the control-space indices above them (raw/PSD/ML/RBF-pose controls share one flat buffer), add_lod clones an existing LOD, every name accepts a single value or a list to add several in one flush. .set_gui_control_rows(target,rows)/.link_gui_control(target,raw,from_value=0.0,to_value=1.0,slope=1.0,cut=0.0)/.unlink_gui_control(target,raw) edit an existing GUI control's mapping rows (target and raw accept an int index or a name; rows are int | name | (raw,) | (raw,from,to,slope,cut); the control's rows are rewritten as one block grouped so each (gui, raw) pair is one consecutive run, because RigLogic sums split runs; set replaces, link appends, unlink drops every row of that raw; a control must keep at least one row (MetaHuman's face tools abort on an unused GUI control) - remove_gui_control drops it outright) - eager like add*, and sets the soundness gate. A fourth kind, removal (input DNA only, also eager, only ever shrinks), mirrors add*: remove_gui_control/remove_raw_control/remove_expression/remove_psd/remove_ml_control/remove_rbf_pose_control/remove_rbf_pose/remove_rbf_solver/remove_neural_network/remove_twist/remove_swing/remove_blendshape_target/remove_metadata(target, force=False) - by default refuses naming every other place the removed thing is still referenced (a full report), force=True cascades through them too, reporting every extra thing it took with it. set*/clear() also covers RBF solver parameters, neural net layers and jbmd (joint_representation) as ordinary staged fields; set_twist/set_swing are eager instead (the correction ledger owns that layer at commit time). merge(other, ...) merges a head/face DNA into this one (the body) by index-space concatenation, eagerly replacing this object's own bytes. A DNA can also be sourced from a file/bytes instead of a pin: mm.dna.load(name, path)/mm.dna.from_bytes(name, data) - the loaded slot supports the full read/write surface above and publishes to its own DNA output pin only if this run mutates it or calls .publish() explicitly; .save_to_file(path) stages a commit-time write of this DNA's own committed bytes. Rig donation: .scaffold_rig(template=None, body_part=None, bone_mapping=None, bone_mapping_inverse=None, retarget_values=True, translation_scale="none") / .transfer_rig(...) donate a template's rig (controls/joint groups/blend shapes/animated maps/RBF/twist-swing/jbmd) onto this DNA (template=None + body_part="head"/"body" auto-locates the shipped archetype); bone_mapping is a dict/list of (a,b) pairs/.mmbonemap path, none given auto-maps by name/hierarchy; retarget_values also converts kept rows via per-(joint,control) quaternion conjugation. mm.dna.auto_bone_map(template, target) previews that mapping with no side effects; mm.dna.save_bone_map(path, pairs, ...)/load_bone_map(path) read/write the shared .mmbonemap JSON format. Every value is raw DNA space, never converted to the app's UE space.

d = mm.dna(); d.remove_joint("Twist_L"); print(d.descriptor()["lod_count"])

mm.ui.button(id_text, tooltip='') / mm.ui.label(text) / mm.ui.separator() / mm.ui.host(where, height=280) / mm.ui.attach(obj) / mm.ui.attach_hwnd(hwnd)

Declare buttons/labels/separators the node's details panel renders as rows, in call order - replaced wholesale every run, not incremental. Capped at 32 elements / 128 characters per string (truncate/drop + a mm.note() explaining it); duplicate button ids within one run: last wins + note. host(where=panel|window) embeds or floats a toolkit window (isolated only; implies Interactive Script). attach() takes tkinter/Qt or an HWND. See the Python UI section below.

mm.ui.host(where="window")

Python UI

mm.ui buttons/labels, mm.event, mm.prior_detail.

What this is

A Wrangle script can declare its own buttons/labels/separators (mm.ui.button()/label()/separator() above), rendered as rows in this node's details panel, above the parameter pin editor - so a shared/reusable Wrangle node can offer a purpose-built mini-UI ("Bake To Attribute", a "Reset" button, ...) instead of an artist hunting for the right ch()/chf() parameter to edit by hand.

mm.ui.button("Bake"); mm.ui.label("Result: %.2f" % value)

Declare every run

Declarations are not incremental - every mm.ui.* call this run makes replaces the whole set the last successful run declared. A script that wants the same button on every run simply calls mm.ui.button(...) every run (the common case - most scripts declare their whole UI unconditionally, near the top).

mm.ui.button("Apply")

Pressing a button

Pressing a declared button (in the panel, or an agent's ui_<snake(id)> action - see agent access below) re-runs the full script - the same PythonScript, not a separate handler - with mm.event set to "button:" for that one run. A script branches on mm.event to decide what a press should do; on an ordinary Run press mm.event is "".

if mm.event == "button:Bake": mm.set_attrib("baked", data, "vertex", "float")

Declarations refresh on success only

ScriptUIElements refreshes only when the run's Status is Ok - a run that raises, times out or is refused leaves the last good buttons on screen, so an artist debugging a broken script still has working controls to fix things with rather than a panel that goes blank the moment something breaks.

Caps

Up to 32 UI elements per run; each of a button's id/tooltip and a label's text is capped at 128 characters. A call past either cap truncates (a string) or drops (the 33rd+ element) rather than raising - always reported with mm.note() naming exactly what happened, on both transports, byte-identical wording.

Duplicate button ids

Two mm.ui.button() calls in the same run with the same id: the last one wins (the earlier declaration is replaced, at the later call's position in the row order), noted via mm.note().

mm.ui.button("Bake", "v1"); mm.ui.button("Bake", "v2")  # only "v2" survives

State across presses - mm.prior_detail(key, default=None)

The designed-but-unwired state channel: mm.set_detail() writes a value this run; mm.prior_detail() reads what the node's own previous successful run wrote - so a button's handler can remember something across presses (a counter, a toggle, a cached path) without an external file. Returns default when the key was never set by a prior run.

n = mm.prior_detail("presses", 0); mm.set_detail("presses", n + 1)

Agent access

Every declared button also shows up as an agent action named "ui_<snake(id)>" - pressing it via the agent's node_action tool is identical to a human clicking the row: the same PressScriptUIButton() call, the same mm.event, the same "same trust decision as Run" rule - declarations are inert data restored on load; nothing runs until a press actually happens.

node_action {"action": "ui_bake"}

Presses while running

A press that arrives while this node is already mid-Run is dropped (never queued) - the panel's own button rows disable themselves the instant a run starts, and a press some other way (an agent action arriving mid-run) gets a plain refusal naming why instead of silently doing nothing.

mm.ui.host(where, height=280) / mm.ui.attach(obj) / mm.ui.attach_hwnd(hwnd)

Isolated only. where is panel (embed in this node's details, Windows HWND) or window (free-floating). A mm.ui.host() call in the script forces isolation and Interactive Script for that run. attach() accepts tkinter/Qt windows (winfo_id/winId) or an HWND int.

mm.ui.host(where="window")