pybonsai 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pybonsai/__init__.py ADDED
File without changes
pybonsai/__main__.py ADDED
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env python3
2
+
3
+
4
+ #
5
+ # #&
6
+ # %&@&
7
+ # &%@% %& %@|
8
+ # &&@@&%#@%_\@@&&@# @=&
9
+ # &#@# #%## ;&@%# %@ @% &%
10
+ # @ % #& %%~| @%@%@&#
11
+ # |;; # %
12
+ # \\ @ % %@%#
13
+ # |~ =;@ __%%
14
+ # =| ~_=___ %&#
15
+ # || /~ % #
16
+ # |// &
17
+ # |=
18
+ # ~|
19
+ # ;|
20
+ # .---. ./||\. .-.
21
+ #
22
+ # I speak for the trees, for the trees have no voice.
23
+ # - The Lorax, 1971
24
+ #
25
+
26
+
27
+ from . import draw
28
+ from . import tree
29
+ import argparse
30
+ import sys
31
+
32
+ import random
33
+ from math import radians
34
+ from os import get_terminal_size
35
+
36
+
37
+ VERSION = "1.2.2"
38
+ DESC = "PyBonsai procedurally generates ASCII art trees in your terminal."
39
+
40
+
41
+ class Options:
42
+ #stores all parameters that can be edited via the command line arguments
43
+
44
+ #default values
45
+ NUM_LAYERS = 8
46
+ INITIAL_LEN = 15
47
+ ANGLE_MEAN = 40
48
+
49
+ LEAF_LEN = 4
50
+
51
+ INSTANT = False
52
+ WAIT_TIME = 0
53
+
54
+ BRANCH_CHARS = "~;:="
55
+ LEAF_CHARS = "&%#@"
56
+
57
+ WINDOW_WIDTH = 80
58
+ WINDOW_HEIGHT = 25
59
+
60
+ FIXED = False
61
+
62
+ def __init__(self):
63
+ #set the default values
64
+ self.num_layers = Options.NUM_LAYERS
65
+ self.initial_len = Options.INITIAL_LEN
66
+ self.angle_mean = radians(Options.ANGLE_MEAN)
67
+
68
+ self.leaf_len = Options.LEAF_LEN
69
+
70
+ self.instant = Options.INSTANT
71
+ self.wait_time = Options.WAIT_TIME
72
+
73
+ self.branch_chars = Options.BRANCH_CHARS
74
+ self.leaf_chars = Options.LEAF_CHARS
75
+
76
+ self.user_set_type = False
77
+ self.type = random.randint(0, 3)
78
+ self.fractal_mode = False
79
+
80
+ self.fixed_window = Options.FIXED
81
+
82
+ self.window_width, self.window_height = self.get_default_window()
83
+
84
+ def get_default_window(self):
85
+ #ensure the default values fit the current terminal size
86
+ width, height = get_terminal_size()
87
+
88
+ #check the default values fit the current terminal
89
+ width = min(width, Options.WINDOW_WIDTH)
90
+ height = min(height, Options.WINDOW_HEIGHT)
91
+
92
+ return width, height
93
+
94
+ def set_seed(self, seed):
95
+ random.seed(seed)
96
+
97
+ #the type must be re-chosen because the rng seed has been changed (this ensures repeatable results)
98
+ if not self.user_set_type:
99
+ self.type = random.randint(0, 3)
100
+
101
+
102
+ def _print_help():
103
+ OPTION_DESCS = f"""OPTIONS:
104
+ -h, --help display help
105
+ --version display version
106
+
107
+ -s, --seed seed for the random number generator
108
+
109
+ -i, --instant instant mode: display finished tree immediately
110
+ -w, --wait time delay between drawing characters when not in instant mode [default {Options.WAIT_TIME}]
111
+
112
+ -c, --branch-chars string of chars randomly chosen for branches [default "{Options.BRANCH_CHARS}"]
113
+ -C, --leaf-chars string of chars randomly chosen for leaves [default "{Options.LEAF_CHARS}"]
114
+
115
+ -x, --width maximum width of the tree [default {Options.WINDOW_WIDTH}]
116
+ -y, --height maximum height of the tree [default {Options.WINDOW_HEIGHT}]
117
+
118
+ -t, --type tree type: integer between 0 and 3 inclusive [default random]
119
+ -S, --start-len length of the root branch [default {Options.INITIAL_LEN}]
120
+ -L, --leaf-len length of each leaf [default {Options.LEAF_LEN}]
121
+ -l, --layers number of branch layers: more => more branches [default {Options.NUM_LAYERS}]
122
+ -a, --angle mean angle of branches to their parent, in degrees; more => more arched trees [default {Options.ANGLE_MEAN}]
123
+
124
+ -f, --fixed-window do not allow window height to increase when tree grows off screen
125
+ """
126
+ usage = "usage: pybonsai [-h] [--version] [-s SEED] [-i] [-w WAIT] [-c BRANCH_CHARS]\n" \
127
+ " [-C LEAF_CHARS] [-x WIDTH] [-y HEIGHT] [-t {{0,1,2,3}}]\n" \
128
+ " [-S START_LEN] [-L LEAF_LEN] [-l LAYERS] [-a ANGLE] [-f]"
129
+
130
+ print(usage)
131
+ print()
132
+ print(DESC)
133
+ print()
134
+ print(OPTION_DESCS)
135
+ exit()
136
+
137
+
138
+ def parse_cli_args():
139
+ if '-h' in sys.argv or '--help' in sys.argv:
140
+ _print_help()
141
+ #convert sys.argv into a dictionary in the form {option_name : option_value}
142
+ options = Options()
143
+ default_width, default_height = options.get_default_window()
144
+
145
+ parser = argparse.ArgumentParser(
146
+ description=DESC,
147
+ epilog = "Based on PyBonsai by Ben Edwards.",
148
+ add_help=False
149
+ )
150
+ parser.add_argument('--version', action='version', version=f'PyBonsai version {VERSION}')
151
+ parser.add_argument('-s', '--seed', type=int)
152
+ parser.add_argument('-i', '--instant', action='store_true')
153
+ parser.add_argument('-w', '--wait', type=float, default=options.wait_time)
154
+ parser.add_argument('-c', '--branch-chars', type=str, default=options.branch_chars)
155
+ parser.add_argument('-C', '--leaf-chars', type=str, default=options.leaf_chars)
156
+ parser.add_argument('-x', '--width', type=int, default=default_width)
157
+ parser.add_argument('-y', '--height', type=int, default=default_height)
158
+ parser.add_argument('-t', '--type', type=int, choices=range(4))
159
+ parser.add_argument('-S', '--start-len', type=int, default=options.initial_len)
160
+ parser.add_argument('-L', '--leaf-len', type=int, default=options.leaf_len)
161
+ parser.add_argument('-l', '--layers', type=int, default=options.num_layers)
162
+ parser.add_argument('-a', '--angle', type=int, default=Options.ANGLE_MEAN)
163
+ parser.add_argument('-f', '--fixed-window', action='store_true')
164
+
165
+ args = parser.parse_args()
166
+
167
+ # Update options with parsed arguments
168
+ options.instant = args.instant
169
+ options.wait_time = args.wait
170
+ options.branch_chars = args.branch_chars
171
+ options.leaf_chars = args.leaf_chars
172
+ options.window_width = args.width
173
+ options.window_height = args.height
174
+ options.initial_len = args.start_len
175
+ options.leaf_len = args.leaf_len
176
+ options.num_layers = args.layers
177
+ options.angle_mean = radians(args.angle)
178
+ options.fixed_window = args.fixed_window
179
+
180
+ if args.seed is not None:
181
+ options.set_seed(args.seed)
182
+
183
+ if args.type is not None:
184
+ options.type = args.type
185
+ options.user_set_type = True
186
+
187
+ return options
188
+
189
+
190
+ def get_tree(window, options):
191
+ root_x = window.width // 2
192
+
193
+ root_y = tree.Tree.BOX_HEIGHT + 4
194
+ root_y = root_y + root_y % 2 #round to nearest even number (odd numbers cause off-by-one errors as chars are twice as tall as they are wide)
195
+
196
+ root_pos = (root_x, root_y)
197
+
198
+ if options.type == 0:
199
+ t = tree.ClassicTree(window, root_pos, options)
200
+ elif options.type == 1:
201
+ t = tree.FibonacciTree(window, root_pos, options)
202
+ elif options.type == 2:
203
+ t = tree.OffsetFibTree(window, root_pos, options)
204
+ else:
205
+ t = tree.RandomOffsetFibTree(window, root_pos, options)
206
+
207
+ return t
208
+
209
+
210
+ def main():
211
+ options = parse_cli_args()
212
+ window = draw.TerminalWindow(options.window_width, options.window_height, options)
213
+
214
+ t = get_tree(window, options)
215
+
216
+ t.draw()
217
+ window.draw()
218
+ window.reset_cursor()
219
+
220
+
221
+ if __name__ == "__main__":
222
+ main()
pybonsai/draw.py ADDED
@@ -0,0 +1,243 @@
1
+ import math
2
+ from . import utils
3
+ import random
4
+ from time import sleep
5
+
6
+
7
+ #ANSI escape codes (https://en.wikipedia.org/wiki/ANSI_escape_code)
8
+ END_COLOUR = "\033[00m"
9
+ HIDE_CURSOR = "\033[?25l" #not supported in all terminals
10
+ SHOW_CURSOR = "\033[?25h" #not supported in all terminals
11
+
12
+ CHAR_THRESHOLD = 0.3
13
+
14
+
15
+ class TerminalWindow:
16
+ CHAR_WIDTH = 1
17
+ CHAR_HEIGHT = 2
18
+
19
+ BACKGROUND_CHAR = " "
20
+
21
+ def __init__(self, width, height, options):
22
+ self.width = width
23
+ self.height = height
24
+
25
+ self.options = options
26
+
27
+ self.chars = [[TerminalWindow.BACKGROUND_CHAR for _ in range(width)] for _ in range(height)]
28
+
29
+ colour_char = lambda self, char, r, g, b: f"\033[38;2;{r};{g};{b}m{char}{END_COLOUR}" #ANSI escape code for 24 bit true colour (which most modern terminals support)
30
+
31
+ def extract_colour(self, coloured_char):
32
+ #get the rgb colour from an ANSI coloured character
33
+ splitted = coloured_char.split(";")
34
+
35
+ r = int(splitted[2])
36
+ g = int(splitted[3])
37
+
38
+ b = ""
39
+ for i in splitted[4]:
40
+ if i == "m":
41
+ break
42
+ else:
43
+ b += i
44
+
45
+ b = int(b)
46
+
47
+ return r, g, b
48
+
49
+ def clear_chars(self):
50
+ self.chars = [[TerminalWindow.BACKGROUND_CHAR for _ in range(self.width)] for _ in range(self.height)]
51
+
52
+ def draw(self):
53
+ print(HIDE_CURSOR, end="")
54
+
55
+ for i in self.chars:
56
+ print("".join(i))
57
+
58
+ print(f"\033[{self.height}A", end="") #move cursor to the top after we have finished
59
+ print(SHOW_CURSOR, end="")
60
+
61
+ self.needs_clear = True
62
+
63
+ def reset_cursor(self):
64
+ #cursor will have been left at the top from drawing, so we need to place it back at the bottom
65
+ print(f"\033[{self.height}B", end="")
66
+
67
+ def plane_to_screen(self, x, y):
68
+ #convert cartesian coords to array indices
69
+ scaled_x = x / TerminalWindow.CHAR_WIDTH
70
+ scaled_y = y / TerminalWindow.CHAR_HEIGHT
71
+
72
+ inx1 = round(self.height - scaled_y)
73
+ inx2 = round(scaled_x)
74
+
75
+ return inx1, inx2
76
+
77
+ def screen_to_plane(self, x, y):
78
+ #convert array indices to cartesian coords (inverse of plane_to_screen())
79
+ swapped_x = y
80
+ swapped_y = self.height - x
81
+
82
+ scaled_x = swapped_x * TerminalWindow.CHAR_WIDTH
83
+ scaled_y = swapped_y * TerminalWindow.CHAR_HEIGHT
84
+
85
+ return scaled_x, scaled_y
86
+
87
+ def increase_height(self, delta_height):
88
+ if self.options.fixed_window:
89
+ return False
90
+
91
+ self.height += delta_height
92
+
93
+ for _ in range(delta_height):
94
+ self.chars.insert(0, [TerminalWindow.BACKGROUND_CHAR for _ in range(self.width)])
95
+
96
+ return True
97
+
98
+ def set_char_instant(self, x, y, char, colour, is_screen_coords):
99
+ if not is_screen_coords:
100
+ x, y = self.plane_to_screen(x, y)
101
+
102
+ #check the point will fit
103
+ if x < 0:
104
+ height_changed = self.increase_height(abs(x))
105
+
106
+ if height_changed:
107
+ x = 0
108
+
109
+ if not 0 <= x < self.height or not 0 <= y < self.width:
110
+ return
111
+
112
+ coloured = self.colour_char(char, colour[0], colour[1], colour[2])
113
+ self.chars[x][y] = coloured
114
+
115
+ def set_char_wait(self, x, y, char, colour, is_screen_coords, wait_time):
116
+ #in non instant mode, we want to draw each new character after it is set
117
+ self.set_char_instant(x, y, char, colour, is_screen_coords)
118
+
119
+ self.draw()
120
+ sleep(wait_time)
121
+
122
+ def get_line_char(self, line):
123
+ theta = line.get_theta()
124
+
125
+ upper = math.pi / 2 * (2 / 3)
126
+ lower = math.pi / 2 * (1 / 3)
127
+
128
+ if abs(theta) > upper:
129
+ return "|"
130
+ elif abs(theta) < lower:
131
+ return "_"
132
+ elif theta > 0:
133
+ return "/"
134
+ else:
135
+ return "\\"
136
+
137
+ def choose_colour(self, colour):
138
+ if type(colour[0]) == int:
139
+ #the colour is not a range, so no choice must be made
140
+ return colour
141
+ elif len(colour[0]) == 2:
142
+ #colour should be random with rgb values in the given range
143
+ rand_colour = []
144
+ for lower, upper in colour:
145
+ value = random.randint(lower, upper)
146
+ rand_colour.append(value)
147
+
148
+ return rand_colour
149
+ else:
150
+ raise Exception("Invalid colour argument")
151
+
152
+ def draw_steep_line(self, start, end, colour, width, char, mid_line):
153
+ start_inx, _ = self.plane_to_screen(*start)
154
+ end_inx, _ = self.plane_to_screen(*end)
155
+
156
+ step = 1 if end_inx > start_inx else -1
157
+
158
+ for inx1 in range(start_inx, end_inx + step, step):
159
+ dists = []
160
+ #get the distance away from the mid line for each cell
161
+ for inx2 in range(self.width):
162
+ x, y = self.screen_to_plane(inx1, inx2)
163
+
164
+ desired_x = mid_line.get_x(y)
165
+ dist = abs(desired_x - x)
166
+
167
+ dists.append([dist, inx2])
168
+
169
+ #draw the n closest cells (n = width)
170
+ dists.sort()
171
+ for i in range(width):
172
+ if i >= len(dists):
173
+ break
174
+
175
+ if random.uniform(0, 1) < CHAR_THRESHOLD:
176
+ chosen_char = random.choice(self.options.branch_chars)
177
+ else:
178
+ chosen_char = char
179
+
180
+ chosen_colour = self.choose_colour(colour)
181
+
182
+ if self.options.instant:
183
+ self.set_char_instant(inx1, dists[i][1], chosen_char, chosen_colour, True)
184
+ else:
185
+ self.set_char_wait(inx1, dists[i][1], chosen_char, chosen_colour, True, self.options.wait_time)
186
+
187
+ def draw_shallow_line(self, start, end, colour, width, char, mid_line):
188
+ _, start_inx = self.plane_to_screen(*start)
189
+ _, end_inx = self.plane_to_screen(*end)
190
+
191
+ step = 1 if end_inx > start_inx else -1
192
+
193
+ for inx2 in range(start_inx, end_inx + step, step):
194
+ dists = []
195
+ #get the distance away from the mid line for each cell
196
+ for inx1 in range(self.height):
197
+ x, y = self.screen_to_plane(inx1, inx2)
198
+
199
+ desired_y = mid_line.get_y(x)
200
+ dist = abs(desired_y - y)
201
+
202
+ dists.append([dist, inx1])
203
+
204
+ #draw the n closest cells (n = width)
205
+ dists.sort()
206
+ for i in range(width):
207
+ if i >= len(dists):
208
+ break
209
+
210
+ if random.uniform(0, 1) < CHAR_THRESHOLD:
211
+ chosen_char = random.choice(self.options.branch_chars)
212
+ else:
213
+ chosen_char = char
214
+
215
+ chosen_colour = self.choose_colour(colour)
216
+
217
+ if self.options.instant:
218
+ self.set_char_instant(dists[i][1], inx2, chosen_char, chosen_colour, True)
219
+ else:
220
+ self.set_char_wait(dists[i][1], inx2, chosen_char, chosen_colour, True, self.options.wait_time)
221
+
222
+ def check_line_bounds(self, start, end):
223
+ #if the line will not fit in the current window, update the window size so that it willoptions
224
+ h1, _ = self.plane_to_screen(*start)
225
+ h2, _ = self.plane_to_screen(*end)
226
+
227
+ room_from_top = min(h1, h2)
228
+
229
+ if room_from_top < 0:
230
+ self.increase_height(abs(room_from_top))
231
+
232
+ def draw_line(self, start, end, colour, width):
233
+ mid_line = utils.Line()
234
+ mid_line.set_end_points(start, end)
235
+
236
+ char = self.get_line_char(mid_line)
237
+
238
+ self.check_line_bounds(start, end)
239
+
240
+ if mid_line.is_vertical or abs(mid_line.m) >= 1:
241
+ self.draw_steep_line(start, end, colour, width, char, mid_line)
242
+ else:
243
+ self.draw_shallow_line(start, end, colour, width, char, mid_line)
File without changes
pybonsai/tree.py ADDED
@@ -0,0 +1,416 @@
1
+ import math
2
+ from . import utils
3
+ import random
4
+
5
+
6
+ class Tree:
7
+ #base tree class - not a drawable tree
8
+ BOX_HEIGHT = 3
9
+ MAX_TOP_WIDTH = 35
10
+
11
+ MOUND_THRESHOLD = 0.1
12
+ SOIL_CHAR_THRESHOLD = 0.1
13
+
14
+ SOIL_CHARS = ".~*"
15
+
16
+ MOUND_WIDTH_MEAN = 2
17
+ MOUND_WIDTH_STD_DEV = 1
18
+
19
+ SOIL_COLOUR = (0, 150, 0)
20
+ BOX_COLOUR = (200, 200, 200)
21
+
22
+ BRANCH_COLOUR = ((200, 255), (150, 255), (0, 0)) #range of rgb values
23
+
24
+ def __init__(self, window, root_pos, options):
25
+ self.window = window
26
+ self.root_x, self.root_y = root_pos
27
+ self.options = options
28
+
29
+ self.box_top_width = self.get_box_width()
30
+
31
+ def get_box_width(self):
32
+ width = min(self.window.width // 3, Tree.MAX_TOP_WIDTH)
33
+
34
+ if width % 2 == 0:
35
+ width += 1 #width should be odd to allow tree to go in middle
36
+
37
+ return width
38
+
39
+ def draw_box(self):
40
+ root_inx1, root_inx2 = self.window.plane_to_screen(self.root_x, self.root_y)
41
+
42
+ for i in range(Tree.BOX_HEIGHT):
43
+ inx1 = root_inx1 + i
44
+ width = self.box_top_width - i * 2
45
+
46
+ for x in range(width):
47
+ inx2 = root_inx2 - width // 2 + x
48
+
49
+ if x == 0:
50
+ char = "\\"
51
+ colour = Tree.BOX_COLOUR
52
+ elif x == width - 1:
53
+ char = "/"
54
+ colour = Tree.BOX_COLOUR
55
+ elif i == 0:
56
+ char = "_"
57
+ colour = Tree.SOIL_COLOUR
58
+ elif i == Tree.BOX_HEIGHT - 1:
59
+ char = "_"
60
+ colour = Tree.BOX_COLOUR
61
+ else:
62
+ if random.uniform(0, 1) < Tree.SOIL_CHAR_THRESHOLD:
63
+ char = random.choice(Tree.SOIL_CHARS)
64
+ else:
65
+ char = " "
66
+
67
+ colour = Tree.SOIL_COLOUR
68
+
69
+ self.window.set_char_instant(inx1, inx2, char, colour, True)
70
+
71
+ self.draw_box_feet(root_inx1, root_inx2)
72
+ self.draw_all_mounds(root_inx1, root_inx2) #must be called after the top layer has been drawn normally
73
+
74
+ def draw_box_feet(self, root_inx1, root_inx2):
75
+ #draw feet for the box
76
+ inx1 = root_inx1 + Tree.BOX_HEIGHT
77
+ offset = self.box_top_width // 2 - Tree.BOX_HEIGHT - 1
78
+
79
+ for sign in range(-1, 2, 2):
80
+ inx2 = root_inx2 + sign * offset
81
+ self.window.set_char_instant(inx1, inx2, "‾", Tree.BOX_COLOUR, True)
82
+
83
+ def draw_all_mounds(self, root_inx1, root_inx2):
84
+ #draw .---._____.--. on top layer
85
+ num_drawn = 0
86
+ for i in range(1, self.box_top_width):
87
+ inx2 = root_inx2 - self.box_top_width // 2 + i
88
+
89
+ if random.uniform(0, 1) < Tree.MOUND_THRESHOLD / (num_drawn + 1):
90
+ num_drawn += 1
91
+ max_width = self.box_top_width - i - 1
92
+
93
+ self.draw_mound(root_inx1, inx2, max_width)
94
+
95
+ def draw_mound(self, inx1, start_inx2, max_width):
96
+ top_width = round(random.normalvariate(Tree.MOUND_WIDTH_MEAN, Tree.MOUND_WIDTH_STD_DEV))
97
+ top_width = min(top_width, max_width - 2)
98
+
99
+ if top_width <= 0:
100
+ return
101
+
102
+ for i in range(top_width + 2):
103
+ inx2 = start_inx2 + i
104
+
105
+ if i == 0 or i == top_width + 1:
106
+ char = "."
107
+ else:
108
+ char = "-"
109
+
110
+ self.window.set_char_instant(inx1, inx2, char, Tree.SOIL_COLOUR, True)
111
+
112
+ def draw_tree_base(self, trunk_width):
113
+ #just add some extra padding at the bottom of the trunk to look nice. Must be called after tree is grown
114
+ inx1, inx2 = self.window.plane_to_screen(self.root_x, self.root_y)
115
+
116
+ left_x = inx2 - trunk_width // 2
117
+ right_x = inx2 + trunk_width // 2
118
+
119
+ #off-by-one error: line drawing moves left to right, causing trunk to be to the left of the center, so move the base right to compensate
120
+ if trunk_width % 2 == 0:
121
+ right_x -= 1
122
+
123
+ self.window.set_char_instant(inx1, left_x - 2, ".", (255, 255, 0), True)
124
+ self.window.set_char_instant(inx1, left_x - 1, "/", (255, 255, 0), True)
125
+ self.window.set_char_instant(inx1, right_x + 1, "\\", (255, 255, 0), True)
126
+ self.window.set_char_instant(inx1, right_x + 2, ".", (255, 255, 0), True)
127
+
128
+
129
+ class RecursiveTree(Tree):
130
+ #all recursive tree types are based off of the fractal canopy: https://en.wikipedia.org/wiki/Fractal_canopy
131
+ ANGLE_STD_DEV = math.radians(8)
132
+
133
+ LEN_SCALE = 0.75
134
+
135
+ MAX_INITIAL_WIDTH = 6
136
+
137
+ def __init__(self, window, root_pos, options):
138
+ super().__init__(window, root_pos, options)
139
+
140
+ def get_end_coords(self, start_x, start_y, length, theta):
141
+ x = start_x + length * math.sin(theta)
142
+ y = start_y + length * math.cos(theta)
143
+
144
+ return x, y
145
+
146
+ def get_initial_params(self):
147
+ initial_width = self.options.initial_len // 5
148
+ initial_angle = random.normalvariate(0, RecursiveTree.ANGLE_STD_DEV)
149
+
150
+ #ensure the width is in a suitable range
151
+ initial_width = max(0, initial_width)
152
+ initial_width = min(RecursiveTree.MAX_INITIAL_WIDTH, initial_width)
153
+
154
+ return initial_width, initial_angle
155
+
156
+
157
+ class ClassicTree(RecursiveTree):
158
+ #trees with a random number of branches on each layer
159
+ MEAN_BRANCHES = 2
160
+ BRANCHES_STD_DEV = 0.5
161
+
162
+ def __init__(self, window, root_pos, options):
163
+ super().__init__(window, root_pos, options)
164
+
165
+ def draw_branch(self, x, y, layer, length, width, theta):
166
+ if layer >= self.options.num_layers:
167
+ leaves = Leaves(self.window, (x, y), self.options)
168
+ leaves.draw()
169
+
170
+ return
171
+
172
+ end_x, end_y = self.get_end_coords(x, y, length, theta)
173
+
174
+ self.window.draw_line((x, y), (end_x, end_y), ClassicTree.BRANCH_COLOUR, round(width))
175
+
176
+ self.draw_end_branches(x, y, layer, length, width, theta)
177
+
178
+ def draw_end_branches(self, start_x, start_y, layer, length, width, theta):
179
+ sign = 1
180
+ num_branches = max(0, round(random.normalvariate(ClassicTree.MEAN_BRANCHES, ClassicTree.BRANCHES_STD_DEV)))
181
+
182
+ step = length / num_branches if num_branches != 0 else 0
183
+
184
+ new_width = max(1, width - 1)
185
+ new_length = length * ClassicTree.LEN_SCALE
186
+
187
+ for i in range(num_branches):
188
+ dist_up_branch = (i + 1) * step #branches are linearly distributed along the parent
189
+ new_theta = theta + sign * random.normalvariate(self.options.angle_mean, ClassicTree.ANGLE_STD_DEV)
190
+
191
+ x, y = self.get_end_coords(start_x, start_y, dist_up_branch, theta) #start point of new branch
192
+
193
+ self.draw_branch(x, y, layer + 1, new_length, new_width, new_theta)
194
+
195
+ sign *= -1 #ensure next branch is on opposite side of the parent
196
+
197
+ def draw(self):
198
+ initial_width, initial_angle = self.get_initial_params()
199
+
200
+ self.draw_box()
201
+ self.draw_tree_base(initial_width)
202
+
203
+ self.draw_branch(self.root_x, self.root_y, 1, self.options.initial_len, initial_width, initial_angle)
204
+
205
+
206
+ class FibonacciTree(RecursiveTree):
207
+ #trees with a fibonacci number of branches on each layer
208
+ def __init__(self, window, root_pos, options):
209
+ super().__init__(window, root_pos, options)
210
+
211
+ self.fib = self.fib_nums()
212
+ self.branch_nums = self.generate_branch_nums()
213
+
214
+ def fib_nums(self):
215
+ fib = [1, 1]
216
+
217
+ for _ in range(self.options.num_layers):
218
+ fib.append(fib[-1] + fib[-2])
219
+
220
+ return fib
221
+
222
+ def generate_branch_nums(self):
223
+ #generate the number of child branches branching off of each parent
224
+ branch_nums = [[1]] #1st index is the layer from the root, 2nd index is the position of the parent branch in its layer
225
+ for i in range(self.options.num_layers):
226
+ num_branches = self.fib[i + 2]
227
+ num_parents = sum(branch_nums[-1])
228
+
229
+ base = num_branches // num_parents
230
+ diff = num_branches - base * num_parents
231
+
232
+ current_nums = [base + 1 if x < diff else base for x in range(num_parents)]
233
+
234
+ random.shuffle(current_nums)
235
+
236
+ branch_nums.append(current_nums)
237
+
238
+ return branch_nums
239
+
240
+ def draw_branch(self, x, y, layer_inx, branch_inx, length, width, theta):
241
+ if layer_inx > self.options.num_layers:
242
+ leaf = Leaves(self.window, (x, y), self.options)
243
+ leaf.draw()
244
+
245
+ return
246
+
247
+ end_x, end_y = self.get_end_coords(x, y, length, theta)
248
+
249
+ self.window.draw_line((x, y), (end_x, end_y), FibonacciTree.BRANCH_COLOUR, round(width))
250
+
251
+ self.draw_end_branches(x, y, layer_inx, branch_inx, length, width, theta)
252
+
253
+ def draw_end_branches(self, start_x, start_y, layer_inx, branch_inx, length, width, theta):
254
+ #draw the child branches off of the end of the parent branch
255
+ sign = 1
256
+ num_branches = self.branch_nums[layer_inx][branch_inx]
257
+ new_width = max(1, width - 1)
258
+
259
+ x, y = self.get_end_coords(start_x, start_y, length, theta)
260
+
261
+ for i in range(num_branches):
262
+ angle = random.normalvariate(self.options.angle_mean, FibonacciTree.ANGLE_STD_DEV)
263
+ new_theta = theta + sign * angle
264
+
265
+ new_len = length * FibonacciTree.LEN_SCALE
266
+
267
+ self.draw_branch(x, y, layer_inx + 1, branch_inx + i, new_len, new_width, new_theta)
268
+
269
+ sign *= -1
270
+
271
+ def draw(self):
272
+ initial_width, initial_angle = self.get_initial_params()
273
+
274
+ self.draw_box()
275
+ self.draw_tree_base(initial_width)
276
+
277
+ self.draw_branch(self.root_x, self.root_y, 1, 0, self.options.initial_len, initial_width, initial_angle)
278
+
279
+
280
+ class OffsetFibTree(FibonacciTree):
281
+ #similar to fibonacci tree, but branches grow from the middle of the parent branch
282
+ def __init__(self, window, root_pos, options):
283
+ super().__init__(window, root_pos, options)
284
+
285
+ def draw_end_branches(self, start_x, start_y, layer_inx, branch_inx, length, width, theta):
286
+ sign = 1
287
+ num_branches = self.branch_nums[layer_inx][branch_inx]
288
+
289
+ step = length / num_branches if num_branches != 0 else 0
290
+
291
+ new_width = max(1, width - 1)
292
+ new_length = length * ClassicTree.LEN_SCALE
293
+
294
+ for i in range(num_branches):
295
+ dist_up_branch = (i + 1) * step #branches are now linearly distributed along the parent branch
296
+ new_theta = theta + sign * random.normalvariate(self.options.angle_mean, OffsetFibTree.ANGLE_STD_DEV)
297
+
298
+ x, y = self.get_end_coords(start_x, start_y, dist_up_branch, theta)
299
+
300
+ self.draw_branch(x, y, layer_inx + 1, branch_inx + i, new_length, new_width, new_theta)
301
+
302
+ sign *= -1
303
+
304
+
305
+ class RandomOffsetFibTree(FibonacciTree):
306
+ #an offset fib tree, but the branches are randomly placed along parent branch and leaves can grow on non end branches
307
+ GROW_END_THRESHOLD = 0.5
308
+
309
+ NON_END_MIN = 0.3
310
+ NON_END_MAX = 0.9
311
+
312
+ def __init__(self, window, root_pos, options):
313
+ super().__init__(window, root_pos, options)
314
+
315
+ def draw_end_branches(self, start_x, start_y, layer_inx, branch_inx, length, width, theta):
316
+ sign = 1
317
+ num_branches = self.branch_nums[layer_inx][branch_inx]
318
+
319
+ new_width = max(1, width - 1)
320
+ new_length = length * ClassicTree.LEN_SCALE
321
+
322
+ need_leaves = True
323
+ for i in range(num_branches):
324
+ grow_at_end = random.uniform(0, 1) < RandomOffsetFibTree.GROW_END_THRESHOLD
325
+
326
+ if grow_at_end:
327
+ #this branch will grow at the end of the parent, so do not draw leaves at the end of the parent
328
+ need_leaves = False
329
+ dist_up_branch = length
330
+ else:
331
+ #this branch will grow at a random distance along the parent (not the end), so we may need leaves at the end of the parent
332
+ dist_up_branch = random.uniform(length * RandomOffsetFibTree.NON_END_MIN, length * RandomOffsetFibTree.NON_END_MAX)
333
+
334
+ new_theta = theta + sign * random.normalvariate(self.options.angle_mean, OffsetFibTree.ANGLE_STD_DEV)
335
+
336
+ x, y = self.get_end_coords(start_x, start_y, dist_up_branch, theta)
337
+
338
+ self.draw_branch(x, y, layer_inx + 1, branch_inx + i, new_length, new_width, new_theta)
339
+
340
+ sign *= -1
341
+
342
+ if need_leaves:
343
+ #no branches have grown at the end of the parent, so we can grow leaves
344
+ end_pos = self.get_end_coords(start_x, start_y, length, theta)
345
+
346
+ leaves = Leaves(self.window, end_pos, self.options)
347
+ leaves.draw()
348
+
349
+
350
+ class FractalTree(RecursiveTree):
351
+ """A simple recursive tree that splits into two branches at the end of each branch."""
352
+ NUM_BRANCHES = 2
353
+
354
+ def __init__(self, window, root_pos, options):
355
+ super().__init__(window, root_pos, options)
356
+
357
+ def draw_branch(self, x, y, layer, length, width, theta):
358
+ if layer >= self.options.num_layers:
359
+ leaves = Leaves(self.window, (x, y), self.options)
360
+ leaves.draw()
361
+ return
362
+
363
+ end_x, end_y = self.get_end_coords(x, y, length, theta)
364
+ self.window.draw_line((x, y), (end_x, end_y), Tree.BRANCH_COLOUR, round(width))
365
+ self.draw_end_branches(x, y, layer, length, width, theta)
366
+
367
+ def draw_end_branches(self, start_x, start_y, layer, length, width, theta):
368
+ sign = 1
369
+ new_width = max(1, width - 1)
370
+ new_length = length * self.LEN_SCALE
371
+
372
+ x, y = self.get_end_coords(start_x, start_y, length, theta)
373
+
374
+ for _ in range(self.NUM_BRANCHES):
375
+ angle = random.normalvariate(self.options.angle_mean, self.ANGLE_STD_DEV)
376
+ new_theta = theta + sign * angle
377
+ self.draw_branch(x, y, layer + 1, new_length, new_width, new_theta)
378
+ sign *= -1
379
+
380
+ def draw(self):
381
+ initial_width, initial_angle = self.get_initial_params()
382
+ self.draw_box()
383
+ self.draw_tree_base(initial_width)
384
+ self.draw_branch(self.root_x, self.root_y, 1, self.options.initial_len, initial_width, initial_angle)
385
+
386
+
387
+ class Leaves:
388
+ NUM_LEAVES = 4
389
+
390
+ def __init__(self, window, branch_end, options):
391
+ self.window = window
392
+ self.branch_x, self.branch_y = branch_end
393
+ self.options = options
394
+
395
+ def draw(self):
396
+ g = utils.Vector(0, -1)
397
+
398
+ for _ in range(Leaves.NUM_LEAVES):
399
+ vel = utils.Vector(random.uniform(-1, 1), random.uniform(-1, 1)) #random starting velocity for the leaves to step along
400
+ vel.normalise()
401
+ pos = utils.Vector(self.branch_x, self.branch_y)
402
+
403
+ for i in range(self.options.leaf_len):
404
+ pos += vel
405
+
406
+ colour = (0, random.randint(75, 255), 0)
407
+ char = random.choice(self.options.leaf_chars)
408
+
409
+ if self.options.instant:
410
+ self.window.set_char_instant(pos.x, pos.y, char, colour, False)
411
+ else:
412
+ self.window.set_char_wait(pos.x, pos.y, char, colour, False, self.options.wait_time)
413
+
414
+ #make the leaves droop downwards by adding some gravity force
415
+ weight = i / self.options.leaf_len
416
+ vel += g * weight
pybonsai/utils.py ADDED
@@ -0,0 +1,66 @@
1
+ import math
2
+
3
+
4
+ class Line:
5
+ #line in form y = mx + c
6
+ def __init__(self):
7
+ self.start = None
8
+ self.end = None
9
+
10
+ self.m = None
11
+ self.c = None
12
+
13
+ self.is_vertical = False
14
+
15
+ def get_y(self, x):
16
+ if not self.is_vertical:
17
+ return self.m * x + self.c
18
+
19
+ def get_x(self, y):
20
+ if self.is_vertical:
21
+ return self.c
22
+ elif self.m != 0:
23
+ return (y - self.c) / self.m
24
+
25
+ def set_end_points(self, start, end):
26
+ self.start = start
27
+ self.end = end
28
+
29
+ if self.start[0] == self.end[0]:
30
+ self.is_vertical = True
31
+ self.m = None
32
+ self.c = self.start[0]
33
+ else:
34
+ self.m = (self.start[1] - self.end[1]) / (self.start[0] - self.end[0])
35
+ self.c = self.start[1] - self.m * self.start[0]
36
+
37
+ def set_gradient(self, m, point):
38
+ self.m = m
39
+ self.c = point[1] - m * point[0]
40
+
41
+ def get_theta(self):
42
+ #get angle above x axis
43
+ if self.is_vertical:
44
+ return math.pi / 2
45
+ else:
46
+ return math.atan(self.m)
47
+
48
+
49
+ class Vector:
50
+ def __init__(self, x, y):
51
+ self.x = x
52
+ self.y = y
53
+
54
+ mag = lambda self: (self.x**2 + self.y**2)**0.5
55
+
56
+ def __iadd__(self, other_vec):
57
+ return Vector(self.x + other_vec.x, self.y + other_vec.y)
58
+
59
+ def __mul__(self, scalar):
60
+ return Vector(self.x * scalar, self.y * scalar)
61
+
62
+ def normalise(self):
63
+ m = self.mag()
64
+
65
+ self.x /= m
66
+ self.y /= m
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.4
2
+ Name: pybonsai
3
+ Version: 0.1.0
4
+ Summary: A project that generates ASCII art trees in your terminal.
5
+ Project-URL: Source, https://github.com/DaEtoJostka/PyBonsai
6
+ Project-URL: Original, https://github.com/Ben-Edwards44/PyBonsai
7
+ Author: Ben Edwards
8
+ Author-email: Ivan Karlov <karlovivan679@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: pybonsai
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+
20
+ # PyBonsai :palm_tree:
21
+
22
+ PyBonsai is a Python script that generates procedural ASCII art trees in the comfort of your terminal.
23
+
24
+ ## About :seedling:
25
+
26
+ <img src="Images/demo.gif" align="right" width="450px">
27
+
28
+ PyBonsai is inspired by the amazing [cbonsai](https://gitlab.com/jallbrit/cbonsai) repository.
29
+ Whereas cbonsai grows bonsai trees, PyBonsai trees look more like trees you would find in a forest (oak, ash and so on).
30
+
31
+ The trees are configurable via CLI options to make them different sizes, more or less complex, grow at different rates, or use a different set of characters. See [useage](#useage-wrench) for more information.
32
+
33
+ Currently, PyBonsai supports 4 different types of tree. Details of these are shown in the [tree types](#tree-types-leaves) section.
34
+
35
+ PyBonsai uses [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code) for colouring characters. Almost all modern terminals will support this but, if yours does not, PyBonsai will not work.
36
+
37
+ If you encounter an issue with PyBonsai, or have an idea for an improvement, do not be afraid to open an issue.
38
+
39
+ ## Installation :link:
40
+
41
+ Requirements:
42
+
43
+ - Python 3.9 or greater
44
+
45
+ To use PyBonsai, you need to first clone the repository:
46
+
47
+ git clone https://github.com/Ben-Edwards44/PyBonsai.git
48
+
49
+ ### Linux
50
+
51
+ To install for all users, run the included `install.sh` script to create a symlink in `/usr/local/bin`
52
+
53
+ cd PyBonsai
54
+ sudo bash install.sh
55
+
56
+ Verify the installation by running:
57
+
58
+ pybonsai --version
59
+
60
+ ### Windows
61
+
62
+ After cloning the repository you need to follow these steps in order to run PyBonsai in your terminal:
63
+
64
+ 1. Install the `pyinstaller` package
65
+
66
+ ```
67
+ pip install pyinstaller
68
+ ```
69
+
70
+ > You may need to run the terminal in admin mode while installing `pyinstaller`
71
+
72
+ 2. Change to the directory containing the cloned repo and run the `pyinstaller` command on `main.py`
73
+
74
+ ```
75
+ cd directory-that-contains-pybonsai
76
+ pyinstaller main.py
77
+ ```
78
+
79
+ This will create a new `dist` folder with an executable of the PyBonsai, you can test it by changing to the `dist` folder and running `main.exe`
80
+
81
+ ```
82
+ cd dist
83
+ main.exe
84
+ ```
85
+
86
+ You should see something like this:
87
+
88
+ <img src="Images/demo-windows.gif" width="450px">
89
+
90
+ <br />
91
+
92
+ Now rename the `main.exe` to `pybonsai.exe`
93
+
94
+ 3. Copy the path of the `dist` folder and add it to your `Path` environment variable
95
+
96
+ <img src="Images/env-example.png" width="450px">
97
+
98
+ <br />
99
+
100
+ Now you can run PyBonsai with the command `pybonsai.exe` or `pybonsai`
101
+
102
+ You can even pass flags
103
+
104
+ ```
105
+ pybonsai --layers 10
106
+ ```
107
+
108
+ ### MacOS
109
+
110
+ Please note that PyBonsai has not been tested on MacOS, but I see no reason why it would not work on Mac OS.
111
+ To install PyBonsai, you will need to clone the repository and add it to your system's PATH.
112
+
113
+ ## Usage :wrench:
114
+
115
+ Run `pybonsai --help` for useage:
116
+
117
+ USEAGE pybonsai [OPTION]...
118
+
119
+ PyBonsai procedurally generates ASCII art trees in your terminal.
120
+
121
+ OPTIONS:
122
+ -h, --help display help
123
+ --version display version
124
+
125
+ -s, --seed seed for the random number generator
126
+
127
+ -i, --instant instant mode: display finished tree immediately
128
+ -w, --wait time delay between drawing characters when not in instant mode [default 0]
129
+
130
+ -c, --branch-chars string of chars randomly chosen for branches [default "~;:="]
131
+ -C, --leaf-chars string of chars randomly chosen for leaves [default "&%#@"]
132
+
133
+ -x, --width maximum width of the tree [default 80]
134
+ -y, --height maximum height of the tree [default 25]
135
+
136
+ -t, --type tree type: integer between 0 and 3 inclusive [default random]
137
+ -S, --start-len length of the root branch [default 15]
138
+ -L, --leaf-len length of each leaf [default 4]
139
+ -l, --layers number of branch layers: more => more branches [default 8]
140
+ -a, --angle mean angle of branches to their parent, in degrees; more => more arched trees [default 40]
141
+
142
+ -f, --fixed-window do not allow window height to increase when tree grows off screen
143
+
144
+ The following images demonstrate the use of the different options:
145
+
146
+ | Effect | Image |
147
+ | -------------------- | -------------------------------------------------- |
148
+ | Big tree | ![big tree](/Images/options/big.png) |
149
+ | Different characters | ![different characters](/Images/options/chars.png) |
150
+ | Longer leaves | ![longer leaves](/Images/options/leafy.png) |
151
+
152
+ ## Tree Types :leaves:
153
+
154
+ PyBonsai supports 4 different tree types. Unless specified with the `--type` option, the tree type will be chosen at random.
155
+
156
+ All tree types are generated recursively and are, essentially, variations on [this](https://www.youtube.com/watch?v=0jjeOYMjmDU) basic fractal tree.
157
+
158
+ | Type | Image | Description |
159
+ | ---------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
160
+ | Classic | ![classic](/Images/types/classic.png) | The number of child branches is normally distributed with $\mu = 2$ and $\sigma = 0.5$. |
161
+ | Fibonacci | ![fib](/Images/types/fib.png) | The number of branches on the $n^{th}$ layer is the $n^{th}$ fibonacci number. |
162
+ | Offset fibonacci | ![offset fib](/Images/types/offset_fib.png) | Similar to above, except child branches grow in the middle of the parent as well as the end. |
163
+ | Random fibonacci | ![random fib](/Images/types/rand_fib.png) | Similar to above, except child branches grow at random positions on the parent and leaves can grow in the middle layers. |
@@ -0,0 +1,11 @@
1
+ pybonsai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pybonsai/__main__.py,sha256=whThCockuq-yZxSyIWsm0Vm41F6oLhp4iK9aMuP_4tY,7607
3
+ pybonsai/draw.py,sha256=N5NMMW2JUsoYxdnCGVlHMRPORe2yPK8sobDinDfgRiA,7862
4
+ pybonsai/tree.py,sha256=JtOhu_k-eukytDK490b_dAvxUnNxkfGn7LCsJRG8jBU,15183
5
+ pybonsai/utils.py,sha256=fw15ZaWe5VKJ7S2J_bLckKBp9cCJa6OP0VFu-HZ5KQs,1545
6
+ pybonsai/tests/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ pybonsai-0.1.0.dist-info/METADATA,sha256=5kWxyoRIYrVs3rBKzFyrZw2A9sSXk1kqJ8EVBhD5KdE,6669
8
+ pybonsai-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ pybonsai-0.1.0.dist-info/entry_points.txt,sha256=y7mX54BHUtifR6-gRpacLx1e0t72ZEiwxHq9hx1K88Q,52
10
+ pybonsai-0.1.0.dist-info/licenses/LICENSE,sha256=tpCk-uRO7nSRK-68a98R1fjH4HX6WM2Wfagn220GdTQ,1058
11
+ pybonsai-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pybonsai = pybonsai.__main__:main
@@ -0,0 +1,7 @@
1
+ Copyright 2025 Ivan Karlov
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.