Code Examples¶
All code examples can also be found on GitHub.
Overview¶
Filename | Description |
---|---|
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.