Code Examples

All code examples can also be found on GitHub.

Overview

FilenameDescription
gui_notebar.py Demonstrates how to show a progress indicator with a message when running a long task.
gui_notes.py Demonstrates how to build and run a modal dialog (a note).
gui_palette.py Demonstrates how to build and run a non-modal dialog (a pallette).
lib_zb_math.py Contains a basic math library used by some of the modeling examples.
mod_curve_lightning.py Demonstrates how to construct curves in a tool.
mod_subtool_array.py Demonstrates how to construct an array of sub tools within a tool.
mod_subtool_export.py Demonstrates how to batch export all subtools into a ZPR.
mod_zsphere_biped.py WIP: Do not ship.
sys_timeline_colors.py Demonstrates how to add and delete keyframes in the timeline of ZBrush.
sys_timeline_turntable.py Demonstrates how to create a turntable animation for the current tool in ZBrush.

Code Examples

Gui

gui_notebar.py

 1"""Demonstrates how to show a progress indicator with a message when running a long task.
 2"""
 3__author__ = "Ferdinand Hoppe"
 4__date__ = "13/08/2025"
 5__copyright__ = "Maxon Computer"
 6
 7from zbrush import commands as zbc
 8import time
 9
10def main() -> None:
11    """
12    """
13    # A long-running task simulation, where we update the note bar text and progress periodically.
14    for i in range(1000):
15        p: float = i / 1000.0
16        zbc.set_notebar_text(f"Script is calculating : {round(100 * p, 2)}%", p)
17        time.sleep(0.0025)
18
19    # Clear the bar once we are done, otherwise it will linger until something else overrides it.
20    zbc.set_notebar_text("", 0)
21
22if __name__ == "__main__":
23    main()
Find this example on GitHub.

gui_notes.py

  1"""Demonstrates how to build and run a modal dialog (a note).
  2
  3ZBrush uses two central UI paradigms: palettes and notes. Palettes are a non-modal form of UI, where
  4the user can interact with the interface without being forced to make a decision. Notes, on the other
  5hand, are modal dialogs that require user interaction before they can return to the main interface.
  6
  7This script implements a simple coffee maker interface. The user can select a coffee type which 
  8changes the UI and then displays a message when the user finalizes the dialog.
  9
 10The script is a port of the original [ZCoffee Maker example](https://tinyurl.com/2vn64m6e) 
 11for ZScript but has been adopted to Python.
 12"""
 13__author__ = "Javier Edo, Ferdinand Hoppe"
 14__date__ = "21/08/2025"
 15__copyright__ = "Maxon Computer"
 16
 17import os
 18import zbrush.commands as zbc
 19
 20# Notes assign IDs to added elements in the order they were added. Showing a note will then
 21# return an integer signifying the action that occurred. We do not really NEED these, but symbols
 22# make code more readable.
 23ID_BTN_BG: int = 1 # The background image, we do not really need this since we disable the button.
 24ID_BTN_1: int = 2 # The five coffee buttons.
 25ID_BTN_2: int = 3
 26ID_BTN_3: int = 4
 27ID_BTN_4: int = 5
 28ID_BTN_5: int = 6
 29ID_BTN_CUP: int = 7 # The cup image button.
 30ID_BTN_CANCEL: int = 8 # The cancel button.
 31ID_BTN_SERVE: int = 9 # The serve button.
 32
 33# The types of messages which are emitted when the user made a choice.
 34MSG_ENJOY_COFFEE: str = "\Cc0c0c0Enjoy your \Cffa000 {coffee_type} \Cc0c0c0 !\n"
 35MSG_NO_COFFEE: str = "\Cffa000\n  What, no coffee?!\n"
 36
 37# The message sent by the dialog. We need this a bit weird construction with a global variable
 38# because we call #run_note inside #freeze, so we cannot use a return value directly.
 39MESSAGE: str = ""
 40
 41def run_note() -> str:
 42    """Runs the note interface for the coffee maker and returns a message when the user made a 
 43    choice.
 44    """
 45    h_size: int = 20  # The horizontal size of a coffee button.
 46    v_size: int = 18  # The vertical size of a coffee button.
 47    v_pos: int = 87  # The vertical offset of a coffee button.
 48    h_pos: int = 23  # The horizontal position of a coffee button.
 49    space: int = 12  # The horizontal space between the coffee buttons.
 50
 51    # The paths of the images used to display the coffee choices. Be careful with relative paths in
 52    # ZBrush scripts. Unlike for a 'normal' CPython VM, we are not running the VM not for this script
 53    # alone. Due to that the working directory is not the script directory but the directory of 
 54    # ZBrush. So, the relative path "images/expresso.jpg" would not point towards the directory next 
 55    # to this script but a directory of the same name in the directory of the ZBrush instance running
 56    # this script. So, we must be verbose with our paths (or change the wd but that is not a good idea).
 57    image_dir: str = os.path.join(os.path.dirname(__file__), "images")
 58    images: list[str] = [os.path.join(image_dir, "expresso.jpg"), 
 59                         os.path.join(image_dir, "cappuccino.jpg"), 
 60                         os.path.join(image_dir, "latte.jpg"),
 61                         os.path.join(image_dir, "mocha.jpg"), 
 62                         os.path.join(image_dir, "flat white.jpg")]
 63    background_image: str = os.path.join(image_dir, "ZCoffeeBack.jpg")
 64
 65    # The currently selected coffee as an index out of the five buttons.
 66    selected_coffee: int = 0
 67
 68    # Start a loop where we build, update, and poll the dialog over and over again. This is sort of 
 69    # the main loop of this dialog.
 70    while True:
 71        # Add a button filling the full canvas which we disable and give an icon. So, we repurpose
 72        # a button to just display the background image.
 73        zbc.add_note_button(name="", 
 74                            icon_path=background_image,
 75                            initially_pressed=False, 
 76                            initially_disabled=True,
 77                            h_rel_position=1, 
 78                            v_rel_position=305, 
 79                            width=320.0)
 80
 81        # Add the five buttons to select a coffee, we use the #selected_coffee to decide if the button
 82        # should have an "x" as the label and if it is in a pressed stated.
 83        for i in range(5):
 84            label: str = "x" if i == selected_coffee else ""
 85            is_pressed: bool = i == selected_coffee
 86            zbc.add_note_button(
 87                name=label,
 88                icon_path="",
 89                initially_pressed=is_pressed,
 90                initially_disabled=False,
 91                h_rel_position=h_pos,
 92                v_rel_position=v_pos + ((v_size + space) * i),
 93                width=h_size,
 94                height=v_size,
 95                bg_color=-1,
 96                text_color=0xffa000, # Direct hex color, alias for zbc.rgb(255, 160, 0)
 97            )
 98
 99        zbc.add_note_button("", images[selected_coffee], False, True, 149, 135)
100        zbc.add_note_button("Cancel", "", False, False, 170, 260, 100, 25)
101        zbc.add_note_button("Serve Now", "", False, False, 30, 260, 100, 25, -1, 0xffa000)
102
103        # The trick is now to dummy open the note dialog to poll its interface state. #action
104        # will hold the value of the last interaction, since we are in a loop here, this can also
105        # happen when the #add_note_button above not really added a new button but just 'updated'
106        # one in an already long running note with which the user already interacted.
107        action: int = zbc.show_note("")
108        global MESSAGE # Make the global MESSAGE variable writeable in this scope.
109
110        # The user clicked the cancel button, we set the message.
111        if action == ID_BTN_CANCEL:
112            MESSAGE = MSG_NO_COFFEE
113            return
114        # The user clicked the serve button, we mangle the selected coffee image name to return a 
115        # message with the name of the selected coffee. E.g., ".../expresso.jpg" -> "expresso"
116        elif action == ID_BTN_SERVE:
117            coffee: str = os.path.basename(images[selected_coffee]).rsplit(".", 1)[0]
118            MESSAGE = MSG_ENJOY_COFFEE.format(coffee_type=coffee)
119            return
120        # The user clicked one of the coffees, we update the selection. #action must be offset
121        # by the first button ID so that we end up with a selection in the range [0, 4] instead
122        # of the button ID range [2, 6].
123        if action in (ID_BTN_1, ID_BTN_2, ID_BTN_3, ID_BTN_4, ID_BTN_5):
124            selected_coffee = action - 2
125
126def main() -> None:
127    """Executed when ZBrush runs this script.
128    """
129    # Freeze the UI while the note interface is running and then display the returned global message.
130    zbc.freeze(run_note)
131    zbc.show_note(text=MESSAGE, item_path="", display_duration=2.0, bg_color=zbc.rgb(125, 125, 125))
132
133
134if __name__ == "__main__":
135    main()
Find this example on GitHub.

gui_palette.py

 1"""Demonstrates how to build and run a non-modal dialog (a pallette).
 2
 3ZBrush uses two central UI paradigms: palettes and notes. Palettes are a non-modal form of UI, where
 4the user can interact with the interface without being forced to make a decision. Notes, on the other
 5hand, are modal dialogs that require user interaction before they can return to the main interface.
 6
 7Palettes can be docked in the UI and contain sub-palettes and UI gadgets such as buttons, sliders, 
 8and switches. This example builds a simple palette with buttons and a slider, demonstrating how to
 9use callbacks to react to interface events.
10"""
11__author__ = "Ferdinand Hoppe"
12__date__ = "13/08/2025"
13__copyright__ = "Maxon Computer"
14
15from zbrush import commands as zbc
16
17def on_click(sender: str) -> None:
18    """Callback function that is called when a button is clicked.
19
20    We could write multiple callback functions for multiple buttons, but #sender allows us to
21    identify which button was clicked, so we can handle multiple buttons with a single function.
22    """
23    if sender.endswith("Button A"):
24        print("Button A clicked")
25    elif sender.endswith("Button B"):
26        print("Button B clicked")
27
28    # At any time we can poll the UI with #get.
29    print(f"Current value of Slider A: {zbc.get('xPalette:Subpalette (Togglable):Slider A')}")
30    print(f"Current value of Switch A: {zbc.get('xPalette:Subpalette (Togglable):Switch A')}")
31
32def on_value_change(sender: str, value: float) -> None:
33    """Callback function that is called when a slider or other value-changing element is changed.
34    """
35    if sender.endswith("Slider A"):
36        print(f"Slider A value changed to {value}")
37    elif sender.endswith("Switch A"):
38        print(f"Switch A toggled to {bool(value)}")
39
40def main() -> None:
41    """Executed when ZBrush runs this script.
42    """
43    # If there is already a palette named "MyPalette", close it, so that we always start from scratch.
44    palette: str = "MyPalette"
45    if zbc.exists(palette):
46         zbc.close(palette)
47
48    # Add a new palette named "MyPalette" to the ZBrush interface which will auto dock to the right.
49    # Then add a subpalette named "Subpalette" to the "MyPalette" palette which has no title bar and
50    # is always open.
51    zbc.add_palette(palette, docking_bar=1)
52    subpalette: str = f"{palette}:Subpalette"
53    zbc.add_subpalette(subpalette, title_mode=2)
54
55    # Adds two buttons to the "Subpalette" subpalette. Instead of a an explicit callback function,
56    # we can also use a lambda function. In fact any callable will work. It also important to 
57    # understand the unit system of interface gadgets.
58
59    # Two buttons which use the #on-click callback and which have an automatic width (0, the default).
60    zbc.add_button(f"{subpalette}:Button A", "Button A Tooltip!", on_click, width=0)
61    zbc.add_button(f"{subpalette}:Button B", "Button B Tooltip!", on_click , width=0)
62    # Two buttons which use a lambda function as the callback and which have a relative width in the
63    # the interval |0, 1]. Since both occupy 100%, they will be each placed in one line.
64    zbc.add_button(f"{subpalette}:Button C", "", lambda item: print(f"{item} pressed"), width=1.0)
65    zbc.add_button(f"{subpalette}:Button D", "", lambda item: print(f"{item} pressed"), width=1.0)
66    # And finally a button with a fixed width in pixels. WARNING: When your gadget is larger than 
67    # the available space, it may not be displayed correctly.
68    zbc.add_button(f"{subpalette}:Button E", "", on_click, width=100)
69
70    # ----------------------------------------------------------------------------------------------
71
72    # Adds a subpalette named "Subpalette (Foldable)" to the "Palette" palette which has a title bar
73    # and a minimize button, and is foldable.
74    subpalette_foldable: str = f"{palette}:Subpalette (Foldable)"
75    zbc.add_subpalette(subpalette_foldable)
76
77    # Adds a slider and a switch to the "Subpalette (Foldable)" subpalette.
78    zbc.add_slider(f"{subpalette_foldable}:Slider A", 2, 1, 0, 10, "Some help text.", 
79                   on_value_change, width=200)
80    zbc.add_switch(f"{subpalette_foldable}:Switch A", True, "Some help text.", on_value_change)
81
82    # Retroactively change the min/max values of the slider from 0-10 to 5-15.
83    zbc.set_min(f"{subpalette_foldable}:Slider A", 5)
84    zbc.set_max(f"{subpalette_foldable}:Slider A", 15)
85
86    # Set the value of an interface element. We can use it both the set numeric values and booleans
87    # such as a toggle.
88    zbc.set(f"{subpalette_foldable}:Slider", 7)
89    zbc.set(f"{subpalette_foldable}:Switch", True)
90
91    # And finally unfold our palette, if we would not do this, it would start our in a folded state.
92    zbc.maximize(subpalette_foldable)
93
94    # Other than for modal notes, there is no main loop or manual polling required. Our UI will work
95    # via the callbacks defined above. But at any point, one of the call backs could call this 
96    # function again to rebuild the UI.
97
98if __name__ == "__main__":
99    main()
Find this example on GitHub.

Modeling

lib_zb_math.py

  1"""Contains a basic math library used by some of the modeling examples.
  2
  3This file does not contain any ZBrush specific functionality. THIS IS STILL A CODE EXAMPLE AND NOT 
  4A FEATURE. We cannot debug or extend this library. But it can be starting point for you to fill some
  5gaps in ZBrush's current API.
  6
  7Functions:
  8
  9    interpolate:    Linearly interpolates between two values.
 10
 11Classes:
 12
 13    Vector:         Represents a three component vector type and provides basic vector operations.
 14    ValueNoise:     Realizes a naive value noise.
 15
 16"""
 17__author__ = "Ferdinand Hoppe"
 18__date__ = "22/08/2025"
 19__copyright__ = "Maxon Computer"
 20
 21import math
 22import typing
 23
 24def interpolate(a: float, b: float, t: float) -> float:
 25    """Linearly interpolates between #a and #b using #t.
 26    """
 27    return a + t * (b - a)
 28
 29class Vector:
 30    """Represents a three component vector type and provides basic vector operations.
 31    """
 32    def __init__(self, x: float = 0.0, y: float = 0.0, z: float = 0.0) -> None:
 33        """Constructs a vector.
 34        """
 35        self.x = x; self.y = y; self.z = z
 36
 37    def __add__(self, other: "Vector") -> "Vector":
 38        """Adds #other to #self and returns the result.
 39        """
 40        return Vector(self.x + other.x, self.y + other.y, self.z + other.z)
 41    
 42    def __iadd__(self, other: "Vector") -> "Vector":
 43        """Adds #other to #self.
 44        """
 45        self.x += other.x; self.y += other.y; self.z += other.z
 46        return self
 47
 48    def __sub__(self, other: "Vector") -> "Vector":
 49        """Subtracts #other from #self and returns the result.
 50        """
 51        return Vector(self.x - other.x, self.y - other.y, self.z - other.z)
 52    
 53    def __isub__(self, other: "Vector") -> "Vector":
 54        """Subtracts #other from #self.
 55        """
 56        self.x -= other.x; self.y -= other.y; self.z -= other.z
 57        return self
 58
 59    def __mul__(self, other: float) -> "Vector":
 60        """Multiplies #self by the scalar #other and returns the result.
 61        """
 62        return Vector(self.x * other, self.y * other, self.z * other)
 63
 64    def __rmul__(self, other: float) -> "Vector":
 65        """Multiplies a scalar by #self and returns the result.
 66        """
 67        return self * other
 68
 69    def __imul__(self, other: float) -> "Vector":
 70        """Multiplies #self by the scalar #other.
 71        """
 72        self.x *= other; self.y *= other; self.z *= other
 73        return self
 74
 75    def __iter__(self) -> typing.Iterator[float]:
 76        """Iterates over the components of the vector.
 77        """
 78        yield self.x
 79        yield self.y
 80        yield self.z
 81
 82    def __repr__(self) -> str:
 83        """Returns a string representation of the vector.
 84        """
 85        return (f"{self.__class__.__name__}({round(self.x, 3)}, {round(self.y, 3)}, "
 86                f"{round(self.z, 3)})")
 87
 88    def dot(self, other: "Vector") -> float:
 89        """Returns the dot product of #self and #other.
 90        """
 91        return self.x * other.x + self.y * other.y + self.z * other.z
 92    
 93    def cross(self, other: "Vector") -> "Vector":
 94        """Returns the cross product of #self and #other.
 95        """
 96        return Vector(
 97            self.y * other.z - self.z * other.y,
 98            self.z * other.x - self.x * other.z,
 99            self.x * other.y - self.y * other.x
100        )
101
102    def length(self) -> float:
103        """Returns the length of #self.
104        """
105        return math.sqrt(self.length_squared())
106
107    def length_squared(self) -> float:
108        """Returns the squared length of #self.
109        """
110        return self.x ** 2 + self.y ** 2 + self.z ** 2
111
112    def normalized(self) -> "Vector":
113        """Returns the unit vector of #self.
114        """
115        length = self.length()
116        if length > 0:
117            return Vector(self.x / length, self.y / length, self.z / length)
118        return Vector()
119
120    def rotate(self, angles: "Vector") -> "Vector":
121        """Rotates #self by the given Euler #angles in radians.
122        """
123        x, y, z = self.x, self.y, self.z
124        cx, cy, cz = map(math.cos, (angles.x, angles.y, angles.z))
125        sx, sy, sz = map(math.sin, (angles.x, angles.y, angles.z))
126        return Vector(
127            x * cy * cz - y * cy * sz + z * sy,
128            x * (sx * sy * cz + cx * sz) + y * (cx * sy * sz - sx * cz) - z * sx * cy,
129            x * (-cx * sy * cz + sx * sz) + y * (sx * sy * sz + cx * cz) + z * cx * cy
130        )
131
132class ValueNoise:
133    """Realizes a naive value noise generator.
134    """
135    SEED: float = 43758.5453
136
137    @staticmethod
138    def hash_31(p: Vector) -> float:
139        """Returns a pseudo random hash value for the given vector #p.
140        """
141        return ((math.sin(p.x * 12.9898 + p.y * 78.233 + p.z * 37.719) * ValueNoise.SEED) % 1.0)
142
143    @staticmethod
144    def noise_31(p: Vector, scale: float = 1.0) -> float:
145        """Returns a noise float value for the given vector #p.
146        """
147        # Get the integer cell coordinate and the local coordinate within the cell for the vector #p.
148        cell: Vector = Vector(math.floor(p.x), math.floor(p.y), math.floor(p.z))
149        local: Vector = Vector(p.x - cell.x, p.y - cell.y, p.z - cell.z)
150
151        # Compute the pseudo random hashes for the eight corners of the cell.
152        v000: float = ValueNoise.hash_31(Vector(cell.x, cell.y, cell.z))
153        v001: float = ValueNoise.hash_31(Vector(cell.x, cell.y, cell.z+1))
154        v010: float = ValueNoise.hash_31(Vector(cell.x, cell.y+1, cell.z))
155        v011: float = ValueNoise.hash_31(Vector(cell.x, cell.y+1, cell.z+1))
156        v100: float = ValueNoise.hash_31(Vector(cell.x+1, cell.y, cell.z))
157        v101: float = ValueNoise.hash_31(Vector(cell.x+1, cell.y, cell.z+1))
158        v110: float = ValueNoise.hash_31(Vector(cell.x+1, cell.y+1, cell.z))
159        v111: float = ValueNoise.hash_31(Vector(cell.x+1, cell.y+1, cell.z+1))
160
161        # Compute the result by trilinear interpolation of the cell corners.
162        x00: float = interpolate(v000, v100, local.x)
163        x01: float = interpolate(v001, v101, local.x)
164        x10: float = interpolate(v010, v110, local.x)
165        x11: float = interpolate(v011, v111, local.x)
166        y0: float = interpolate(x00, x10, local.y)
167        y1: float = interpolate(x01, x11, local.y)
168        return interpolate(y0, y1, local.z) * scale
169
170    @staticmethod
171    def noise_13(p: float, scale: float = 1.0) -> Vector:
172        """Returns a noise vector for the given float value #p.
173        """
174        return Vector(
175            ValueNoise.noise_31(Vector(p, 0, 0), scale),
176            ValueNoise.noise_31(Vector(0, p, 0), scale),
177            ValueNoise.noise_31(Vector(0, 0, p), scale)
178        )
179    
180    @staticmethod
181    def turbulence_13(p: float, scale: float = 1.0, octaves: int = 4, frequency: float = 1.0, 
182                      amplitude: float = 1.0) -> Vector:
183        """Returns a turbulence vector for the given float value #p.
184        """
185        res: Vector = Vector(0.0, 0.0, 0.0)
186        for _ in range(octaves):
187            res += ValueNoise.noise_13(p * frequency, scale) * amplitude
188            frequency *= 2.0
189            amplitude *= 0.5
190        return res
Find this example on GitHub.

mod_curve_lightning.py

  1"""Demonstrates how to construct curves in a tool.
  2
  3Curves are an important part of ZBrush's tool set. We can use the API to create new curves (but
  4we currently cannot read or modify existing curves). This example creates multiple curves deformed
  5by a value noise to generate output that resembles lightning arcs.
  6
  7The script requires the document to have an editable PolyMesh3D tool to be activated. The script
  8will then select the "CurveTube" brush and create multiple lightning arcs in the active tool.
  9
 10You can run this example multiple times in a row and it will generate a different effect each time.
 11Comment out the line `ValueNoise.SEED = random.uniform(0, 1000000)` to make the example deterministic.
 12
 13Note:
 14    By default you will only see the curve output, to see the brush applied you must move one of the
 15    created curves a tiny bit. You can of course also switch the curve brush before applying the 
 16    curves.
 17"""
 18__author__ = "Ferdinand Hoppe"
 19__date__ = "22/08/2025"
 20__copyright__ = "Maxon Computer"
 21
 22from zbrush import commands as zbc
 23
 24import math
 25import random
 26import os
 27import sys
 28
 29# --- Code for importing the local math library next to this script --------------------------------
 30
 31# Extend the module search paths and import the math library next to this script.
 32script_dir: str = os.path.dirname(__file__)
 33if not script_dir in sys.path:
 34    sys.path.insert(0, script_dir)
 35
 36from lib_zb_math import Vector, ValueNoise
 37
 38# It is important that we clean up after ourself, as this is shared Python instance. And our module
 39# might collide with the module of the same name imported by another script.
 40sys.path.remove(script_dir)
 41sys.modules.pop("lib_zb_math", None)
 42
 43# --- Start of curve example -----------------------------------------------------------------------
 44
 45def create_lightning(branches: tuple[int, int, int], step_size: float = 0.1, 
 46                     min_length: float = 0.25, max_length: float = 2.0) -> None:
 47    """Generates random lightning curves.
 48
 49    Args:
 50        branches: A tuple describing the number of branches of the lightning effect.
 51                    - 0: The number of main branches.
 52                    - 1: The minimum number of sub-branches per main branch.
 53                    - 2: The maximum number of sub-branches per main branch.
 54        step_size: The resolution of the curve. This value is interpreted in the coordinate system 
 55                   of the active tool, i.e., the scale of the effect scales with the tool it is
 56                   applied to.
 57        min_length: The minimum length of a main branch.
 58        max_length: The maximum length of a main branch.
 59    """
 60    # Start a new set of curves, deleting any existing not yet applied curves.
 61    zbc.new_curves()
 62
 63    # Loop over the main branches.
 64    for _ in range(branches[0]):
 65        # Create a new curve, chose a random length for it, and compute the number of steps, i.e.,
 66        # points that will fit into it. Finally, compute a random direction for the curve.
 67        cid: int = zbc.add_new_curve()
 68        length: float = random.uniform(min_length, max_length)
 69        steps: int = int(length / step_size)
 70        direction: Vector = Vector(random.uniform(0, 2 * math.pi), 
 71                                   random.uniform(0, 2 * math.pi), 
 72                                   random.uniform(0, 2 * math.pi))
 73        
 74        # Now build the points for the curve. #t is the relative offset along the curve, e.g., 0.5
 75        # for 50% and #p is the absolute offset, e.g., 5 units at t=0.5 for a 10 units long curve.
 76        points: list[Vector] = []
 77        for t, q in [(i / steps, i * step_size) for i in range(steps)]:
 78            # Compute base point, i.e., the line point for #t. We just construct a line along the
 79            # z-axis and then rotate it into position.
 80            p: Vector = Vector(0, 0, q).rotate(direction)
 81            # Now we sprinkle a bit of noise onto our point to achieve the 'lightning' effect. We
 82            # multiply the effect by #t so that it is null at the tip of the curve and the strongest
 83            # at its end. We add cid to the relative offset #q so that each curve is unique.
 84            p += ValueNoise.turbulence_13(cid + q, 2.0, 8) * t
 85
 86            # And finally add the point to the curve and our list.
 87            zbc.add_curve_point(cid, p.x, p.y, p.z)
 88            points.append((t, p))
 89
 90        # Now we are going to create the sub-branches. This could of course be done recursively to
 91        # generate high fidelity lightning effects but we keep it simple here. We pick a random 
 92        # number of sub-branches and then iterate over them.
 93        for _ in range(random.randint(branches[1], branches[2])):
 94            # Now we are going to pick a relative offset #t along the main branch where the sub-
 95            # branch should start. We compute a random offset and raise it by 1/2 to make it more 
 96            # likely that we pick a value at the end of the curve. Then we search for the point 
 97            # #mPoint in our points whose relative offset #mt is larger than or equal to #t.
 98            t: float = random.uniform(0, 1) ** 0.5
 99            mt, mPoint = next(((mt, mPoint) for mt, mPoint in points if mt >= t), (0, 0))
100
101            # And now it is basically just the same over again.The only thing that is slightly 
102            # different is that we use the relative offset #mt along the main branch to scale the
103            # sub-branch and that the sub-branch starts at #mPoint instead of (0, 0, 0).
104            sub_cid: int = zbc.add_new_curve()
105            sub_length: float = random.uniform(min_length * mt * 0.3, max_length * mt * 0.3)
106            sub_steps: int = int(sub_length / step_size)
107            sub_direction: Vector = Vector(random.uniform(0, 2 * math.pi), 
108                                           random.uniform(0, 2 * math.pi), 
109                                           random.uniform(0, 2 * math.pi))
110
111            for st, sp in [(i / sub_steps, i * step_size) for i in range(sub_steps)]:
112                p: Vector = mPoint + Vector(0, 0, sp).rotate(sub_direction)
113                p += ValueNoise.turbulence_13(sub_cid + sp, 2.0, 8) * st * mt
114                zbc.add_curve_point(sub_cid, p.x, p.y, p.z)
115
116    # Copy all the curves we created to the UI.
117    zbc.curves_to_ui()
118
119def main() -> None:
120    """Executed when cell_zBrush runs this script.
121    """
122    # Make sure that the active tool is editable and enable the CurveTube brush.
123    if not zbc.is_enabled("Transform:Edit"):
124        return zbc.show_note("Active tool is not editable.", display_duration=2.0)
125    
126    if not zbc.exists("Brush:CurveTube"):
127        return zbc.show_note("No 'CurveTube' brush found.", display_duration=2.0)
128    
129    zbc.set("Transform:Edit", True)
130    zbc.press("Brush:CurveTube")
131    zbc.set("Draw:Draw Size", 10)
132
133    # Randomize the value noise seed and then create lightning effect. Be careful with Python's
134    # random.seed function, it is extremely expensive to call. One call is absolutely fine but
135    # you should not write scripts which call this function hundreds or thousands of times. Use
136    # then something like #ValueNoise.hash_31 instead.
137    
138    ValueNoise.SEED = random.uniform(0, 1000000)
139    random.seed(ValueNoise.SEED)  # Ensure reproducibility for the example.
140
141    create_lightning(branches=(3, 4, 8), # number of main branches and min max sub-branch count.
142                     step_size=0.01,     # The resolution of the curve.
143                     min_length=6.0,     # The minimum length of a main branch.
144                     max_length=10.0)    # The maximum length of a main branch.
145
146if __name__ == "__main__":
147    main()
Find this example on GitHub.

mod_subtool_array.py

 1"""Demonstrates how to construct an array of sub tools within a tool.
 2
 3Primarily highlights how to select a tool by name and automate getting its PolyMesh3D derivate. Also
 4discusses (but does not show) some alternative routes. The script will create a 3x3x2 array of 
 5colorized Cube3D sub tools. You can change the generated sub-tool with the module attribute 
 6#TOOL_NAME.
 7
 8You must draw into the canvas after the script ran to see its tool result.
 9"""
10__author__ = "Ferdinand Hoppe, Jan Wesbuer"
11__date__ = "22/08/2025"
12__copyright__ = "Maxon Computer"
13
14import itertools
15import re
16
17TOOL_NAME: str = "Cube3D" # The name of the tool to create an array of.
18
19import zbrush.commands as zbc
20
21def make_poly_mesh_from_tool(tool_name: str) -> None:
22    """Creates an an editable PolyMesh3D from the specified tool #tool_name.
23    """
24    # One of the main "tricks" when working with tools and brushes is to ignore most of the 
25    # specialized API functions such as #get_tool_count or #get_tool_path and instead work with item
26    # paths. Every loaded tool will have an item path such as "Tool:Cube3D" or "Tool:Sphere3D". So, 
27    # we can simply check if such item exists and then click it.
28
29    # Check if there is already an existing PolyMesh3D tool with the given name, and if so, 
30    # select it.
31    tool_path: str = f"Tool:{tool_name}"
32    poly_tool_path: str = f"Tools:PM3D_{tool_name}"
33    if zbc.exists(poly_tool_path):
34        zbc.press(poly_tool_path)
35        return
36
37    if zbc.exists(tool_path):
38        zbc.press(tool_path)
39        zbc.press("Tool:Make PolyMesh3D")
40    else:
41        raise RuntimeError(f"There is no tool named '{tool_name}' in the current ZBrush session.")
42
43def create_tool_array(tool_name: str, shape: tuple[int], offsets: tuple[float], 
44                      colorize: bool) -> None:
45    """Clones the tool with the given #tool_name in an array with the given #shape and #offsets.
46
47    Args:
48        tool_name (str): The tool name of the tool to clone.
49        shape (tuple[int]): The shape of the array (x, y, z).
50        offsets (tuple[float]): The offsets for each axis (x, y, z).
51        colorize (bool): Whether to colorize the duplicated tools.
52    """
53    # Enable Mrgb draw mode when we should colorize the clones and then activate the given tool.
54    if colorize: 
55        zbc.press("Draw:Mrgb")
56
57    # Create a PolyMesh 3D for the given #tool_name.
58    make_poly_mesh_from_tool(tool_name)
59
60    # Now iterate over the shape of the array. itertools.product yields the cartesian product for
61    # its inputs, i.e., their combinations. It is the same as three nested loops.
62    for ix, iy, iz in itertools.product(range(shape[0]), range(shape[1]), range(shape[2])):
63
64        # Compute the position and color for this clone. The position is just the product of the
65        # current index (ix, iy, iz) and the offsets. The color is just a makeshift gradient based
66        # on the current index.
67        x: float = offsets[0] * ix
68        y: float = offsets[1] * iy
69        z: float = offsets[2] * iz
70        r: int = int(((ix + 1) / (shape[0])) * 255)
71        g: int = int(((iy + 1) / (shape[1])) * 255)
72        b: int = int(((iz + 1) / (shape[2])) * 255)
73
74        # Create a new sub tool for the tool, and then set its position and color.
75        zbc.press("Tool:SubTool:Duplicate")
76        zbc.set("Tool:Geometry:X Position", x)
77        zbc.set("Tool:Geometry:Y Position", y)
78        zbc.set("Tool:Geometry:Z Position", z)
79        zbc.set_color(r, g, b)
80        zbc.press("Color:FillObject")
81
82def main() -> None:
83    """Executed when ZBrush runs this script.
84    """
85    # Create a 3x3x2 array of #TOOL_NAME tools with a spacing of 3 units on each axis between 
86    # each sub tool.
87    create_tool_array(tool_name=TOOL_NAME,
88                      shape=(3, 3, 2),
89                      offsets=(3.0, 3.0, 3.0),
90                      colorize=True)
91
92if __name__ == "__main__":
93    main()
Find this example on GitHub.

mod_subtool_export.py

 1"""Demonstrates how to batch export all subtools into a ZPR.
 2
 3This script is meant to be used with the -batch command line argument of ZBrush. Its invocation 
 4syntax is:
 5
 6    $ZBRUSH_BIN <path_to_zbrush_file_to_load> -batch -script <path_to_this_script> <path_to_output_dir>
 7
 8For example:
 9
10    /Applications/Maxon ZBrush 2026/ZBrush.app/Contents/MacOS/ZBrush \
11    /Applications/Maxon ZBrush 2026/ZProjects/DemoSoldier.ZPR -script \
12    /path/to/batch_export_subtools.py /path/to/output/dir
13"""
14__author__ = "Javier Edo"
15__date__ = "21/08/2025"
16__copyright__ = "Maxon Computer"
17
18import os
19import sys
20import zbrush.commands as zbc
21
22def export_subtools(export_directory=""):
23    if not os.path.isdir(export_directory):
24        print(f"Invalid export directory specified: {export_directory}")
25        return False
26
27    # Get the number of SubTools in the current tool
28    subtool_count = zbc.get_subtool_count()
29    if subtool_count == 0:
30        print("No SubTools found to export.")
31        return False
32
33    exported_files = 0
34    # Loop through all SubTools
35    for i in range(subtool_count):
36        # Set the active SubTool
37        zbc.select_subtool(i)
38        
39        # Check if the SubTool is visible (status flag 0x01 means visible)
40        status = zbc.get_subtool_status()
41        if status & 0x01:
42            # Get the full path of the active tool, which includes the SubTool name
43            full_tool_path = zbc.get_active_tool_path()
44            subtool_name = full_tool_path.rsplit('/')[-1] # Extract name after the last slash
45
46            # Create the final output path for the OBJ file
47            output_path = os.path.join(export_directory, f"{subtool_name}.obj")
48
49            # Set this as the next file name for ZBrush's exporter
50            zbc.set_next_filename(output_path)
51            
52            # Press the "Export" button in the Tool palette
53            zbc.press("Tool:Export")
54            print(f"Exported SubTool '{subtool_name}' to {output_path}")
55            exported_files += 1
56
57    return exported_files
58
59
60def export_subtools_with_ui(export_directory=""):
61    if not os.path.isdir(export_directory):
62        save_path = zbc.ask_filename("*.obj", "Select a folder and enter a dummy file name", 
63                                     "Choose Export Directory")
64        if not save_path:
65            zbc.message_ok("Export cancelled by user.")
66            return False
67        export_directory = os.path.dirname(save_path)
68
69    exported_files = export_subtools(export_directory)
70    if exported_files > 0:
71        zbc.message_ok(f"Export complete! {exported_files} SubTools were saved to:\n{export_directory}")
72    else:
73        zbc.message_ok("No SubTools were exported.")
74
75
76if __name__ == '__main__':
77    if "-script" not in sys.argv or (sys.argv.index("-script") + 1)>= len(sys.argv):
78        print("This script requires the -script flag followed by the args to the script.")
79        sys.exit(1)
80
81    # args to actual script (put after the -script "/path/to/script.py" args)
82    script_args = sys.argv[sys.argv.index("-script") + 2:]
83    export_directory = script_args[0] if script_args else ""
84
85    ret_code = not export_subtools(export_directory)
86    sys.exit(ret_code)
Find this example on GitHub.

mod_zsphere_biped.py

  1"""WIP: Do not ship.
  2"""
  3__author__ = "Ferdinand Hoppe"
  4__date__ = "22/08/2025"
  5__copyright__ = "Maxon Computer"
  6
  7from zbrush import commands as zbc
  8
  9import weakref
 10import os
 11import sys
 12import typing
 13
 14# --- Code for importing the local math library next to this script --------------------------------
 15
 16# Extend the module search paths and import the math library next to this script.
 17script_dir: str = os.path.dirname(__file__)
 18if not script_dir in sys.path:
 19    sys.path.insert(0, script_dir)
 20
 21from lib_zb_math import Vector
 22
 23# It is important that we clean up after ourself, as this is shared Python instance. And our module
 24# might collide with the module of the same name imported by another script.
 25sys.path.remove(script_dir)
 26sys.modules.pop("lib_zb_math", None)
 27
 28# --- Start of curve example -----------------------------------------------------------------------
 29
 30
 31class ZSphere:
 32    """Abstracts ZSphere operations in ZBrush.
 33    """
 34    ID_COUNT: int = 0  # The index of the ZSphere count property.
 35    ID_X_POS: int = 1  # The index of the X position property.
 36    ID_Y_POS: int = 2  # The index of the Y position property.
 37    ID_Z_POS: int = 3  # The index of the Z position property.
 38    ID_RADIUS: int = 4  # The index of the radius property.
 39    ID_COLOR: int = 5  # The index of the color property.
 40    ID_PARENT_INDEX: int = 7  # The index of the parent index property.
 41    ID_CHILD_COUNT: int = 11
 42
 43    def __init__(self, position: Vector, radius: float, color: int = 16777215) -> None:
 44        """Initializes the ZSphere instance.
 45        """
 46        self.children: list["ZSphere"] = []
 47        self.color: int = color
 48        self.index: int = 0
 49        self.radius: float = radius
 50        self.position: Vector = position
 51
 52        self._index_counter: int = 0
 53        self._parent: weakref.ReferenceType["ZSphere"] | None = None
 54
 55    def __repr__(self) -> str:
 56        """Returns a string representation of the ZSphere instance.
 57        """
 58        return (f"{self.__class__.__name__}(id={self.index}, pos={self.position}, "
 59                f"color={self.color}, radius={round(self.radius, 3)})")
 60
 61    def __iter__(self) -> typing.Iterator["ZSphere"]:
 62        """Iterates over #self and all its children in a depth-first manner.
 63        """
 64        yield self
 65        for child in self.children:
 66            yield from child
 67
 68    def find(self, index: int) -> "ZSphere | None":
 69        """Returns the ZSphere instance with the given index, or None if not found.
 70        """
 71        if self.index == index:
 72            return self
 73        for child in self.children:
 74            result = child.find(index)
 75            if result:
 76                return result
 77
 78    def add(self, child: "ZSphere") -> "ZSphere":
 79        """Adds a child ZSphere instance with the given properties.
 80        """
 81        if not isinstance(child, ZSphere):
 82            raise TypeError("Argument #child must be an instance of ZSphere.")
 83
 84        child.index = self.next_index
 85        child._parent = weakref.ref(self)
 86        self.children.append(child)
 87        return child
 88
 89    def delete(self, index: int | None = None) -> None:
 90        """Deletes the ZSphere instance with the given index.
 91        """
 92        index = index or self.index
 93        node: ZSphere | None = self.find(index)
 94        if node is None:
 95            raise ValueError(f"ZSphere with index {index} not found.")
 96
 97        if node.parent is not None:
 98            node.parent.children.remove(node)
 99
100    @property
101    def hull_x_pos(self) -> float:
102        """Returns the x+ position on the hull of sphere in global coordinates.
103        """
104        return self.position + Vector(self.radius * .5, 0, 0)
105    
106    @property
107    def hull_x_neg(self) -> float:
108        """Returns the x- position on the hull of sphere in global coordinates.
109        """
110        return self.position - Vector(self.radius * .5, 0, 0)
111
112    @property
113    def hull_y_pos(self) -> float:
114        """Returns the y+ position on the hull of sphere in global coordinates.
115        """
116        return self.position + Vector(0, self.radius * .5, 0)
117
118    @property
119    def hull_y_neg(self) -> float:
120        """Returns the y- position on the hull of sphere in global coordinates.
121        """
122        return self.position - Vector(0, self.radius * .5, 0)
123
124    @property
125    def hull_z_pos(self) -> float:
126        """Returns the z+ position on the hull of sphere in global coordinates.
127        """
128        return self.position + Vector(0, 0, self.radius * .5)
129
130    @property
131    def hull_z_neg(self) -> float:
132        """Returns the z- position on the hull of sphere in global coordinates.
133        """
134        return self.position - Vector(0, 0, self.radius * .5)
135
136    @property
137    def parent(self) -> "ZSphere | None":
138        """Returns the parent ZSphere instance, or None if it doesn't exist.
139        """
140        return self._parent() if self._parent else None
141
142    @property
143    def root(self) -> "ZSphere":
144        """Returns the top-most ZSphere instance.
145        """
146        current: ZSphere = self
147        while current.parent:
148            current = current.parent
149
150        return current
151
152    @property
153    def next_index(self) -> int:
154        """Returns the next available index for a new child ZSphere instance.
155        """
156        root: "ZSphere" = self.root
157        root._index_counter += 1
158        return root._index_counter
159
160    @property
161    def count(self) -> int:
162        """Returns the total number of nodes in the tree below #self.
163        """
164        count: int = 1
165        for child in self.children:
166            count += child.count
167
168        return count
169
170    def print_tree(self, indent_count: int = 0, indent: str = "    ") -> None:
171        """Prints the node tree of #self.
172        """
173        print(indent * indent_count + f"{self}")
174        for child in self.children:
175            child.print_tree(indent_count + 1, indent)
176
177    def set_scene(self) -> None:
178        """
179        """
180        def edit() -> None:
181            """
182            """
183            # Get the root of this tree and then add count - 1 spheres to the scene. -1 because the
184            # root sphere does already exist in the scene.
185            root: ZSphere = self.root
186            for i in range(root.count - 1):
187                zbc.add_zsphere(0, 0, 0, 1, 0, 0, 0, 0, 0)
188
189            # Now set all the data. We could have done some of this already in the #add_zsphere call
190            # above but we centralize everything here.
191            for node in root:
192                zbc.set_zsphere(ZSphere.ID_X_POS, node.index, node.position.x)
193                zbc.set_zsphere(ZSphere.ID_Y_POS, node.index, node.position.y)
194                zbc.set_zsphere(ZSphere.ID_Z_POS, node.index, node.position.z)
195                zbc.set_zsphere(ZSphere.ID_COLOR, node.index, node.color)
196                zbc.set_zsphere(ZSphere.ID_RADIUS, node.index, node.radius)
197                if node.parent:
198                    zbc.set_zsphere(ZSphere.ID_PARENT_INDEX,
199                                    node.index, node.parent.index)
200
201        ZSphere.flush_scene()
202        zbc.edit_zsphere(edit)
203
204    @staticmethod
205    def get_scene_count() -> int:
206        """Returns the total number of ZSphere instances in the scene.
207        """
208        try:
209            return int(zbc.get_zsphere(property=0, index=0, sub_index=0))
210        except Exception as e:
211            raise RuntimeError("Failed to get ZSphere count. Likely no ZSphere tool active.") from e
212
213    @staticmethod
214    def flush_scene() -> None:
215        """Flushes the current ZSphere scene data.
216
217        Warning:
218
219            This opens an edit handle, do not call within an already opened edit_zsphere() context.
220        """
221        def delete_all_nodes() -> None:
222            """
223            """
224            count: int = ZSphere.get_scene_count()
225
226            # Reset the root sphere to default values because it cannot be deleted.
227            zbc.set_zsphere(ZSphere.ID_X_POS, 0, 0.0)
228            zbc.set_zsphere(ZSphere.ID_Y_POS, 0, 0.0)
229            zbc.set_zsphere(ZSphere.ID_Z_POS, 0, 0.0)
230            zbc.set_zsphere(ZSphere.ID_COLOR, 0, 16777215.0)
231            zbc.set_zsphere(ZSphere.ID_RADIUS, 0, 1.0)
232
233            # Delete all remaining spheres. It is really important to do this in reverse order, as
234            # ZBrush will not let you delete ZSpheres whose parent you have already deleted. But 
235            # they will still exist, i.e., you will only end up deleting a subset of the spheres. 
236            # This approach assume that children have higher indices than their parents. Which 
237            # usually holds true but there is no hard guarantee. The safer approach would be to 
238            # build a ZSphere (this class) tree and then implement LRN depth first traversal,
239            # so that you can delete the nodes from the inside out.
240            for i in reversed(range(1, count)):
241                zbc.delete_zsphere(i)
242
243        zbc.edit_zsphere(delete_all_nodes)
244
245    @staticmethod
246    def get_scene() -> "ZSphere":
247        """Builds a tree from the current ZSphere scene data.
248        """
249        def get(i: int) -> ZSphere:
250            """Builds a ZSphere instance from the ZSphere with the given index.
251            """
252            # 1=xPos,2=yPos,3=zPos,4=radius,5=color,6=mask,7=ParentIndex,8=unused,9=TimeStamp,
253            # 10=unused,11=unused,12=unused,13=unused,14=flags,15=Twist Angle,16=Membrane,17=X Res,
254            # 18=Y Res,19=Z Res,20=XYZ Res,21=UserValue
255
256            print(f"{i}: child count = {zbc.get_zsphere(10, i, 0)}, ")
257            print(f"{i}: sub index = {zbc.get_zsphere(11, i, 0)}, ")
258            return ZSphere(
259                position=Vector(
260                    zbc.get_zsphere(ZSphere.ID_X_POS, i, 0),
261                    zbc.get_zsphere(ZSphere.ID_Y_POS, i, 0),
262                    zbc.get_zsphere(ZSphere.ID_Z_POS, i, 0)
263                ),
264                radius=zbc.get_zsphere(ZSphere.ID_RADIUS, i, 0),
265                color=zbc.get_zsphere(ZSphere.ID_COLOR, i, 0)
266            )
267        
268        count: int = ZSphere.get_scene_count()
269        root: ZSphere = get(0)
270        for i in range(1, count):
271            pid: int = int(zbc.get_zsphere(ZSphere.ID_PARENT_INDEX, i, 0))
272            parent: ZSphere | None = root.find(pid)
273            if not parent:
274                raise RuntimeError(f"Parent ZSphere with index {pid} not found.")
275
276            parent.add(get(i))
277
278        return root
279
280
281def inspect() -> None:
282    """
283    """
284    ZSphere.get_scene().print_tree()
285
286
287def build_biped(head_radius: float = 0.35,
288                neck_length: float = 0.35,
289                neck_radius: float = 0.1,
290                chest_length: float = 0.35,
291                chest_radius: float = 0.5,
292                shoulder_width: float = 0.75,
293                shoulder_radius: float = 0.3,
294                arm_upper_length: float = 0.4,
295                arm_upper_radius: float = 0.2,
296                arm_lower_length: float = 0.3,
297                arm_lower_radius: float = 0.1,
298                hand_size: float = 0.5,
299                torso_height: float = 1.0,
300                hip_width: float = 0.5,
301                thigh_length: float = 1.0,
302                shin_length: float = 1.0,
303                foot_length: float = 0.5) -> None:
304    """Builds a biped ZSphere structure.
305    """
306    
307    head: ZSphere = ZSphere(Vector(0, 0, 0), head_radius)
308    neck: ZSphere = ZSphere(head.hull_y_neg - Vector(0, neck_length, 0), neck_radius)
309    chest: ZSphere = ZSphere(neck.hull_y_neg - Vector(0, chest_length, 0), chest_radius)
310    l_shoulder: ZSphere = ZSphere(chest.hull_x_neg - Vector(shoulder_width / 2, 0, 0),
311                                  shoulder_radius)
312    r_shoulder: ZSphere = ZSphere(chest.hull_x_pos + Vector(shoulder_width / 2, 0, 0),
313                                  shoulder_radius)
314    l_arm_upper: ZSphere = ZSphere(l_shoulder.hull_y_neg - Vector(0, arm_upper_length, 0),
315                                   arm_upper_radius)
316    l_arm_lower: ZSphere = ZSphere(l_arm_upper.hull_y_neg - Vector(0, arm_lower_length, 0),
317                                   arm_lower_radius)
318    r_arm_upper: ZSphere = ZSphere(r_shoulder.hull_y_neg - Vector(0, arm_upper_length, 0),
319                                   arm_upper_radius)
320    r_arm_lower: ZSphere = ZSphere(r_arm_upper.hull_y_neg - Vector(0, arm_lower_length, 0),
321                                   arm_lower_radius)
322
323    head.add(neck)
324    neck.add(chest)
325    chest.add(l_shoulder)
326    chest.add(r_shoulder)
327    l_shoulder.add(l_arm_upper)
328    l_arm_upper.add(l_arm_lower)
329    r_shoulder.add(r_arm_upper)
330    r_arm_upper.add(r_arm_lower)
331
332    head.set_scene()
333
334
335def main() -> None:
336    """Executed when cell_zBrush runs this script.
337    """
338    ZSphere.flush_scene()
339    build_biped()
340    # inspect()
341    # ZSphere.flush_scene()
342
343
344if __name__ == "__main__":
345    main()
Find this example on GitHub.

System

sys_timeline_colors.py

 1"""Demonstrates how to add and delete keyframes in the timeline of ZBrush.
 2
 3ZBrush divides its timeline into purpose bound tracks such as camera, color, material, or tool
 4animations. This script creates ten color keyframes in the color track of the document. The script
 5also explains the normalized time format ZBrush is using.
 6"""
 7__author__ = "Ferdinand Hoppe"
 8__date__ = "21/08/2025"
 9__copyright__ = "Maxon Computer"
10
11from zbrush import commands as zbc
12
13def main() -> None:
14    """Executed when ZBrush runs this script.
15    """
16    # Make sure the timeline is unfolded.
17    if not zbc.get("Movie:TimeLine:Show"):
18        zbc.set("Movie:TimeLine:Show", True)
19
20    # Activate track edit mode, activate the color track, and delete all existing keyframes in it.
21    zbc.set("Movie:TimeLine Tracks:Edit", True)
22    zbc.set("Movie:TimeLine Tracks:Color", True)
23    count: int = zbc.get_keyframes_count()
24    if count > 0:
25        print(f"Deleting {count} color keyframes.")
26        for i in range(count):
27            zbc.delete_keyframe(i)
28
29    # Now we get the length of the timeline because ZBrush makes the a bit odd choice to specify
30    # all time values in its API as document normalized values. So, for example, when we have a 
31    # document which is 10 seconds long, and we want to create a keyframe at 5 seconds, we have to 
32    # pass 0.5 as the normalized time value (because 5 / 10 = 0.5).
33    max_time: float = zbc.get("Movie:TimeLine:Duration")
34    if (max_time < 10.0):
35        zbc.show_note("Cannot create keyframes in a timeline shorter than 10 seconds.", 
36                      display_duration=2.0)
37        
38    # Define ten colors for ten color key frames to generate.
39    color_list: list[tuple[int, int, int]] = [
40        (255, 0, 0), (125, 125, 0), (0, 255, 0), (0, 125, 125), (0, 0, 255), (125, 0, 125),
41        (255, 0, 255), (255, 125, 0), (255, 255, 0), (0, 255, 255),
42    ]
43
44    # Create ten keyframes for the defined colors with a spacing of one second each.
45    for i, color in enumerate(color_list):
46        # Set the current color.
47        zbc.set_color(*color)
48        # Compute the normalized document time for #i seconds and then set a keyframe.
49        time: float = i / max_time
50        zbc.new_keyframe(time)
51
52    print(f"Timeline has {zbc.get_keyframes_count()} color keyframes.")
53
54if __name__ == "__main__":
55    main()
Find this example on GitHub.

sys_timeline_turntable.py

 1"""Demonstrates how to create a turntable animation for the current tool in ZBrush.
 2
 3This script will frame the current tool and create a turntable animation by rotating the camera 
 4around it. The animation will always span over full timeline duration of the document.
 5
 6Note:
 7    See `sys_timeline_colors.py` for a more basic example on timeline animations, explaining the 
 8    key concepts.
 9"""
10__author__ = "Ferdinand Hoppe"
11__date__ = "21/08/2025"
12__copyright__ = "Maxon Computer"
13
14from zbrush import commands as zbc
15
16def main() -> None:
17    """Executed when ZBrush runs this script.
18    """
19    # Make sure the timeline is unfolded, the Camera track is active, and delete all existing 
20    # camera keyframes.
21    zbc.set("Movie:TimeLine:Show", True)
22    zbc.set("Movie:TimeLine Tracks:Edit", True)
23    zbc.set("Movie:TimeLine Tracks:Camera", True)
24    for i in range(zbc.get_keyframes_count()):
25        zbc.delete_keyframe(i)
26
27    # Now get the canvas and active tool (a.k.a. mesh) dimensions, and then figure out the largest 
28    # axis of the mesh. query_mesh3d returns the bounding box (property 2) in the format (min_x,
29    # min_y, min_z, max_x, max_y, max_z).
30    canvas_width: float = zbc.get("Document:Width")
31    canvas_height: float = zbc.get("Document:Height")
32    try:
33        mesh_bounds: tuple[float, float, float, float, float, float] = zbc.query_mesh3d(2, None)
34    except:
35        print("No Polygon mesh found. Please load a polygonal mesh to run this script.")
36        return
37
38    mesh_width: float = abs(mesh_bounds[3] - mesh_bounds[0])
39    mesh_height: float = abs(mesh_bounds[4] - mesh_bounds[1])
40    mesh_depth: float = abs(mesh_bounds[5] - mesh_bounds[2])
41    mesh_max_size: float = max(mesh_width, mesh_height, mesh_depth)
42    
43    # Now we compute a scaling factor over the smallest axis of the canvas and the largest axis of 
44    # the mesh. Simply put, when tool would fit 200 times on its largest axis into the smallest axis
45    # of the canvas, we want 200 to be our scaling factor. We could make this more complicated by
46    # doing this on a more intricate per axis basis but this is good enough for an example. We scale 
47    # this value by 75% so that we have a nice safe frame, as our mesh would otherwise touch the
48    # borders of the frame.
49    scale_factor: float = (min(canvas_width, canvas_height) / mesh_max_size) * 0.75
50    print(f"Mesh bounds: {mesh_bounds}")
51    print(f"Canvas size: {canvas_width} x {canvas_height}")
52    print(f"Mesh size: {mesh_width} x {mesh_height} x {mesh_depth}")
53    print(f"Scale factor: {scale_factor}")
54
55    # Now we can compute and animate our camera transform. The position and scale are fixed, because
56    # ZBrush actually moves the tool when we manipulate the camera.
57    position: tuple[float] = (canvas_width / 2, canvas_height / 2, 0)
58    scale: tuple[float] = (scale_factor, scale_factor, scale_factor)
59
60    # We want to rotate around the y-axis. So, we animate it to 0°, 90°, 180°, 270° and 360° in four
61    # steps. But as you can see, we also animate the z-axis by 180° between the first two and
62    # following frames : (0, 0, [0]) -> (0, 90, [180]). This is because we use here simple Euler
63    # angles and we are otherwise subject to interpolation flipping ("gimbal lock"). The tool would
64    # flip on its head between the first two keyframes and then again between the second and third.
65    # So, we compensate by also animating the z-axis.
66    rotations: list[tuple[float]] = [(0, 0, 0), (0, 90, 180), (0, 180, 180), (0, 270, 0), (0, 360, 0)]
67    count: int = len(rotations) - 1
68
69    # Now we just loop over our rotations and animate them. We make here use of the normalized
70    # document time ZBrush uses to place each rotation value at the corresponding time in percent
71    # in the document timeline.
72    for i, rotation in enumerate(rotations):
73        zbc.set_transform(*position, *scale, *rotation)
74        zbc.new_keyframe(i / count)
75
76if __name__ == "__main__":
77    main()
Find this example on GitHub.