Working with Materials
Library and scene materials
RPTS works with two kinds of material, and the materials API covers both:
- Library materials are the
.mtlxmaterials in the material library. They are grouped into categories, which are the folders they live in.rpts.materials.list_library()returns them. - Scene materials belong to the loaded asset itself, for example per-part materials that came with a CAD or character model.
rpts.materials.list_scene_materials()returns them.
Both calls return a list of dicts. Library entries have name, path, category and original_name. Scene entries have name, path and category. Materials in a root folder of the library have an empty category.
# this script is written for RPTS 0.9.8
import rpts
def list_materials():
print("Library materials:")
for material in rpts.materials.list_library():
print(f" [{material['category'] or 'root'}] {material['name']} -> {material['path']}")
print("Scene materials:")
for material in rpts.materials.list_scene_materials():
print(f" {material['name']} -> {material['path']}")
list_materials()
Use the path value from these lists when you call assign(), assign_from_csv() or any other function that takes a material. It is always a string.
Assigning a material to nodes
rpts.materials.assign(nodes, material_path) assigns one material to one or more nodes:
nodescan be a node path (a string), anrpd.Node, or a list of either. A group node is expanded to all the mesh nodes below it, so you can pass a group directly.material_pathis thepathstring of the material, not the dict.- The whole batch is one undoable operation.
- The return value is the number of meshes whose material actually changed. Meshes that already had the material are not counted.
The next script assigns a library material, found by name, to every node that is currently selected:
# this script is written for RPTS 0.9.8
import rpts
def assign_to_selection(material_name):
# find the library material by name
matches = [m for m in rpts.materials.list_library() if m["name"] == material_name]
if not matches:
print(f"No library material named '{material_name}'.")
return
if len(matches) > 1:
categories = [m["category"] or "root" for m in matches]
print(f"'{material_name}' is ambiguous, it exists in: {categories}. Nothing assigned.")
return
material = matches[0]
# use the current selection as the target
selected = rpts.get_selected_nodes()
if not selected:
print("Nothing selected, nothing assigned.")
return
changed = rpts.materials.assign([node.path for node in selected], material["path"])
print(f"Assigned '{material_name}' to {changed} mesh(es).")
assign_to_selection("<material-name>")
Scoping the export to a selection
One part where Python automation specifically comes in handy is working with materials, and their assignments to the 3D model. The built-in rpts.materials.export_assignments_to_csv(csv_path) / assign_from_csv(csv_path) pair (covered below under Scene-wide CSV round trip) already round-trips the entire scene in one call each. Writing your own export script is useful when you'd rather scope it to your current selection, add extra columns of your own, or otherwise customize the format - and it also shows how to walk a node's children yourself.
Running the export below and then immediately reimporting the CSV it just wrote, with nothing changed in between, is a no-op by design - it only demonstrates the mechanism. The real-world trigger for actually needing this is a CAD reimport (rpts.reimport_asset(), covered on the processing and reimport page) happening between the export and the reimport-from-CSV step. Reimporting re-reads the asset from its source file, which is when material assignments actually get lost - and restoring them from the CSV you exported beforehand is exactly what this workflow is for.
Let's have a look at the following example. It reads the material that is currently assigned to every mesh node under your current selection (walking down into all of their children too), and writes that out to a CSV file. To walk down the hierarchy, every node can be indexed like a list - node[i] returns its i-th direct child Node, and len(node) returns how many children it has - which the helper below uses to collect a node and everything beneath it into a list:
# this script is written for RPTS 0.9.8
# please make sure that "csv_file_path" is correct on your system
import csv
import rpts
csv_file_path = "C:\\Path\\to\\your\\output\\directory\\materials.csv"
def get_subtree(node):
# Returns a list with node and all of its descendants.
nodes = [node]
for i in range(len(node)):
nodes.extend(get_subtree(node[i]))
return nodes
def export_hierarchy_to_csv(output_csv_path="scene_materials.csv"):
# access the currently loaded model/asset
asset = rpts.get_asset()
if asset is None:
print("No model currently loaded.")
return
# fetch the currently selected nodes, and walk down into every one of
# their children too, so selecting a single group also covers all of
# its parts. If nothing is selected, fall back to the whole scene.
selected = rpts.get_selected_nodes()
if not selected:
print("Nothing selected - exporting the whole scene instead.")
selected = [asset.root_node]
nodes_data = []
seen_paths = set()
for root in selected:
for node in get_subtree(root):
if node.path in seen_paths:
continue
seen_paths.add(node.path)
# only nodes that reference a mesh have an associated material
# nodes without a mesh (node.mesh_index == -1) are skipped
if node.mesh_index == -1:
continue
mesh = asset.mesh(node.mesh_index)
if mesh.num_materials == 0:
material_name = ""
else:
material_name = asset.material(mesh.material_index(0)).name
nodes_data.append([node.path, material_name, node.name])
# export to CSV (first two columns match the built-in node/material_name
# format, so this file can be re-imported with assign_from_csv(); the
# third column is an extra, optional addition of our own)
with open(output_csv_path, mode="w", newline="", encoding="utf-8") as csv_file:
writer = csv.writer(csv_file)
# write CSV header
writer.writerow(["node", "material_name", "node_name"])
# write node rows
writer.writerows(nodes_data)
print(
f"Successfully exported {len(nodes_data)} mesh node(s) to '{output_csv_path}'."
)
export_hierarchy_to_csv(csv_file_path)
So this script above, when run from RPTS (RapidPipeline Twin Studio) should create a CSV file (don't forget to adapt the variable csv_file_path to your particular output file for the CSV with the material assignments).
The resulting CSV file lists, for each mesh node under your selection, the material that is actually assigned to it right now, under the columns node and material_name - the same two column names the built-in export_assignments_to_csv() uses - plus a third, optional node_name column of our own. A mesh node with no material assigned gets an empty material_name cell; group nodes are skipped entirely, since they have no mesh (and therefore no material) of their own. Materials are read from slot 0 of each mesh - RPTS assigns materials directly per mesh node, there is no "inherited from parent" concept to account for.
Because the first two columns match the built-in format exactly, this CSV can be read back with the exact same call as any other CSV in this format - no separate reimport function needed, since csv.DictReader only reads the columns it asks for by name and silently ignores the extra node_name column:
# this script is written for RPTS 0.9.8
import rpts
assigned, skipped = rpts.materials.assign_from_csv(csv_file_path)
print(f"Assigned {assigned} row(s), skipped {len(skipped)}.")
See Scene-wide CSV round trip below for exactly how names are resolved, what the library= argument does, and how to interpret skipped.
Scene-wide CSV round trip
Two functions save and restore which material is assigned to which mesh, for the whole scene:
rpts.materials.export_assignments_to_csv(csv_path)writes a CSV with the columnsnodeandmaterial_name, one row per mesh node in the scene. It returns the number of rows written. Only material slot 0 of each mesh is recorded. Group nodes are not written.rpts.materials.assign_from_csv(csv_path, library=None)reads that CSV and assigns each material to its node. It returns a tuple(assigned, skipped), whereskippedis a list of(node_path, material_name, reason)tuples.
A row's material name is looked up in the scene materials first, then in the library. Rows that cannot be assigned are skipped, with a reason such as node not found, material not found, no material specified or ambiguous material name.
This pair covers the whole scene in one call, with a fixed 2-column format. Use it for a quick, scene-wide round trip; use the manual walk above when you want to scope the export to a selection, add extra columns, or otherwise customize the format.
Restricting the lookup with library=
Material names are not unique across libraries. After using export_scene_as_library() (see below) to save your scene's edited materials into a new library, the same name can exist in both the scene and the new library. Pass library with the category name to resolve names only inside that one library:
# this script is written for RPTS 0.9.8
import rpts
def export_assignments(csv_path):
# write the current assignments of every mesh node to a CSV file
rows = rpts.materials.export_assignments_to_csv(csv_path)
print(f"Wrote {rows} row(s) to '{csv_path}'.")
def reassign_from_library(csv_path, library_name):
# re-apply previously saved assignments, resolving names only in one library
assigned, skipped = rpts.materials.assign_from_csv(csv_path, library=library_name)
print(f"Assigned {assigned} row(s), skipped {len(skipped)}.")
for node_path, material_name, reason in skipped:
print(f" - {node_path} -> {material_name}: {reason}")
# run this before reimporting / before whatever change you want to be able to undo
export_assignments("C:\\Path\\to\\your\\file.csv")
# ... reimport the asset, or make whatever other change may alter material assignments ...
# run this afterwards, using the same csv_path, to restore the saved assignments
reassign_from_library("C:\\Path\\to\\your\\file.csv", "<library-name>")
Keep these as two separate steps, called at two separate points in time - never combine them back into a single function that exports and then immediately reassigns. export_assignments() overwrites whatever was previously saved at csv_path with a fresh snapshot of the scene's current state, so re-running it against a file you want to preserve destroys the very snapshot you meant to restore later. Only call reassign_from_library() against a CSV you are not simultaneously re-exporting - that is, call export_assignments() once before the change you want to undo, and reassign_from_library() afterwards, without exporting again in between.
With library= set, scene materials are not searched at all. Use it when the CSV refers to materials from that library. For an unedited round trip of a model's own original materials, leave library at its default of None.
You can edit the CSV by hand, as long as the column names stay node and material_name. Then re-apply it with assign_from_csv().
Exporting the scene's edited materials as a new library
rpts.materials.export_scene_as_library(library_name) exports every material used in the scene into a new library folder named library_name. The new folder appears as a category in the material library panel straight away.
This call captures each material's current edited values: colors, roughness, textures, UV transforms and so on. It is not only a record of which material is assigned where. The CSV functions above only round-trip assignments, never the values of the materials themselves, so this call is the way to save materials you edited in RPTS as reusable library entries.
The call returns one dict per scene material that was attempted, with name, success, path and, on failure, error. The next script exports the scene under a library name you choose and prints which materials succeeded and which failed:
# this script is written for RPTS 0.9.8
import rpts
def export_scene_materials(library_name):
results = rpts.materials.export_scene_as_library(library_name)
succeeded = [r for r in results if r["success"]]
failed = [r for r in results if not r["success"]]
print(f"Exported {len(succeeded)} of {len(results)} material(s) to library '{library_name}':")
for r in succeeded:
print(f" OK {r['name']} -> {r['path']}")
for r in failed:
print(f" FAILED {r['name']}: {r['error']}")
return results
export_scene_materials("<library-name>")
The scene itself is not changed by this export. Scene materials keep their original names in the new library. So if a scene material and a library material now share the same name, use the library= argument described above to pick the one you want.
See processing and reimport for reimport_asset() itself - the step that, as noted above, is the real-world reason to export and re-apply material assignments with the CSV workflows on this page.