├── tests ├── example.blend ├── test_tree.py ├── plot_tree.py └── testdata.csv ├── README.md ├── mst_blender ├── __init__.py ├── mstree.py ├── diameter.py └── mst_blender.py └── LICENSE /tests/example.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pherbers/MST-Dendrites/HEAD/tests/example.blend -------------------------------------------------------------------------------- /tests/test_tree.py: -------------------------------------------------------------------------------- 1 | import mstree 2 | import numpy as np 3 | import cProfile 4 | import csv 5 | import time 6 | 7 | f = open('testdata_large.csv', 'r') 8 | reader = csv.reader(f, delimiter=";", quoting=csv.QUOTE_NONNUMERIC) 9 | points = np.array([row for row in reader]) 10 | v = [1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000] 11 | for vv in v: 12 | points = np.random.rand(vv,2) * 10 - 5 13 | points[0] = (0,0) 14 | # cProfile.run('mstree.mstree(points, threshold = 100, balancing_factor = 0.0)') 15 | t1 = time.time() 16 | tree = mstree.mstree(points, balancing_factor = 0.0) 17 | t2 = time.time() 18 | print(vv, '\t', t2-t1) 19 | # mstree.add_quad_diameter(tree) -------------------------------------------------------------------------------- /tests/plot_tree.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import mstree 3 | import numpy as np 4 | 5 | def drawTree(root_node): 6 | plt.figure() 7 | plt.scatter(points[:,0], points[:,1]) 8 | drawTreeRecursive(root_node) 9 | plt.show() 10 | 11 | def drawTreeRecursive(root_node): 12 | for child in root_node.children: 13 | print('Plotting from point ' + str(root_node.index) + ' to ' + str(child.index)) 14 | plt.plot((root_node.pos[0], child.pos[0]), (root_node.pos[1], child.pos[1]),'-') 15 | drawTreeRecursive(child) 16 | 17 | if __name__ == '__main__': 18 | points = np.random.rand(100,2) * 10 - 5 19 | points[0] = (0,0) 20 | # plt.scatter(points[:,0], points[:,1]) 21 | # plt.show() 22 | # f = open('testdata.csv', 'r') 23 | # reader = csv.reader(f, delimiter=";", quoting=csv.QUOTE_NONNUMERIC) 24 | # points = np.array([row for row in reader]) 25 | # print(points) 26 | root = mstree.mstree(points, threshold = 50, balancing_factor = 0) 27 | drawTree(root) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MST-Dendrites 2 | Realistic artificial dendrites using minimum spanning trees and Blender 3D. A short tutorial on how to use it can be found [here](https://www.youtube.com/watch?v=18Us4noy6z8). 3 | 4 | ## mstree.py 5 | mstree.py gives access to functions for creating minimum spanning trees with balancing factor. The work is based on [a paper for synthetic neuronal structures](http://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1000877) by Hermann Cuntz. 6 | Given a set of points, the algorithm calculates a minimum spanning tree on them. 7 | 8 | ## Blender Addon 9 | mst_blender is an addon for [Blender 3D](blender.org) to create minimum spanning trees directly in Blender. 10 | To install, copy the mst_blender folder into your Blender script directory (minimum Blender version 2.70/2.80). 11 | Two new GUI-Panels will show up in your Tools panel, where you can adjust settings for your MST. 12 | As of Blender 2.80, you will find the operators in `Add->Mesh->Minimum Spanning Tree`. 13 | 14 | This addon was mainly developed to create Dendritic structures in Blender as a bachelor thesis, but it can be used to just create minimum spanning trees when using a balancing factor of 0. 15 | -------------------------------------------------------------------------------- /mst_blender/__init__.py: -------------------------------------------------------------------------------- 1 | from . import mst_blender 2 | import bpy 3 | 4 | bl_info = { 5 | "name": "Minimum Spanning Tree (MST)", 6 | "description": "Addon for creating minimum spanning trees", 7 | "category": "Add Curve", 8 | "author": "Patrick Herbers", 9 | "blender": (2, 80, 0), 10 | } 11 | 12 | def menu_draw(self, context): 13 | layout = self.layout 14 | layout.menu("VIEW3D_MT_minimum_spanning_tree", 15 | text="Minimum Spanning Tree", 16 | icon="PARTICLES") 17 | 18 | class VIEW3D_MT_minimum_spanning_tree(bpy.types.Menu): 19 | # Define the "Single Vert" menu 20 | bl_idname = "VIEW3D_MT_minimum_spanning_tree" 21 | bl_label = "Minimum Spanning Tree" 22 | 23 | def draw(self, context): 24 | layout = self.layout 25 | layout.operator_context = 'INVOKE_REGION_WIN' 26 | layout.operator("object.add_minimum_spanning_tree") 27 | layout.operator("object.add_mst_dendrites") 28 | 29 | def register(): 30 | registerclasses() 31 | # Add menu entry 32 | bpy.types.VIEW3D_MT_mesh_add.append(menu_draw) 33 | 34 | def unregister(): 35 | unregisterclasses() 36 | # Remove menu entry 37 | bpy.types.VIEW3D_MT_mesh_add.remove(menu_draw) 38 | 39 | classes = ( 40 | mst_blender.OBJECT_OT_dendritedelete, 41 | mst_blender.OBJECT_OT_dendriteadd, 42 | mst_blender.OBJECT_OT_mstadd, 43 | VIEW3D_MT_minimum_spanning_tree 44 | ) 45 | 46 | registerclasses, unregisterclasses = bpy.utils.register_classes_factory(classes) 47 | 48 | -------------------------------------------------------------------------------- /tests/testdata.csv: -------------------------------------------------------------------------------- 1 | 50;50;0 2 | 81.4724;16.2182;0 3 | 90.5792;79.4285;0 4 | 12.6987;31.1215;0 5 | 91.3376;52.8533;0 6 | 63.2359;16.5649;0 7 | 9.7540;60.1982;0 8 | 27.8498;26.2971;0 9 | 54.6882;65.4079;0 10 | 95.7507;68.9215;0 11 | 96.4889;74.8152;0 12 | 15.7613;45.0542;0 13 | 97.0593; 8.3821;0 14 | 95.7167;22.8977;0 15 | 48.5376;91.3337;0 16 | 80.0280;15.2378;0 17 | 14.1886;82.5817;0 18 | 42.1761;53.8342;0 19 | 91.5736;99.6135;0 20 | 79.2207; 7.8176;0 21 | 95.9492;44.2678;0 22 | 65.5741;10.6653;0 23 | 3.5712;96.1898;0 24 | 84.9129; 0.4634;0 25 | 93.3993;77.4910;0 26 | 67.8735;81.7303;0 27 | 75.7740;86.8695;0 28 | 74.3132; 8.4436;0 29 | 39.2227;39.9783;0 30 | 65.5478;25.9870;0 31 | 17.1187;80.0068;0 32 | 70.6046;43.1414;0 33 | 3.1833;91.0648;0 34 | 27.6923;18.1847;0 35 | 4.6171;26.3803;0 36 | 9.7132;14.5539;0 37 | 82.3458;13.6069;0 38 | 69.4829;86.9292;0 39 | 31.7099;57.9705;0 40 | 95.0222;54.9860;0 41 | 3.4446;14.4955;0 42 | 43.8744;85.3031;0 43 | 38.1558;62.2055;0 44 | 76.5517;35.0952;0 45 | 79.5200;51.3250;0 46 | 18.6873;40.1808;0 47 | 48.9764; 7.5967;0 48 | 44.5586;23.9916;0 49 | 64.6313;12.3319;0 50 | 70.9365;18.3908;0 51 | 75.4687;23.9953;0 52 | 27.6025;41.7267;0 53 | 67.9703; 4.9654;0 54 | 65.5098;90.2716;0 55 | 16.2612;94.4787;0 56 | 11.8998;49.0864;0 57 | 49.8364;48.9253;0 58 | 95.9744;33.7719;0 59 | 34.0386;90.0054;0 60 | 58.5268;36.9247;0 61 | 22.3812;11.1203;0 62 | 75.1267;78.0252;0 63 | 25.5095;38.9739;0 64 | 50.5957;24.1691;0 65 | 69.9077;40.3912;0 66 | 89.0903; 9.6455;0 67 | 95.9291;13.1973;0 68 | 54.7216;94.2051;0 69 | 13.8624;95.6135;0 70 | 14.9294;57.5209;0 71 | 25.7508; 5.9780;0 72 | 84.0717;23.4780;0 73 | 25.4282;35.3159;0 74 | 81.4285;82.1194;0 75 | 24.3525; 1.5403;0 76 | 92.9264; 4.3024;0 77 | 34.9984;16.8990;0 78 | 19.6595;64.9115;0 79 | 25.1084;73.1722;0 80 | 61.6045;64.7746;0 81 | 47.3289;45.0924;0 82 | 35.1660;54.7009;0 83 | 83.0829;29.6321;0 84 | 58.5264;74.4693;0 85 | 54.9724;18.8955;0 86 | 91.7194;68.6775;0 87 | 28.5839;18.3511;0 88 | 75.7200;36.8485;0 89 | 75.3729;62.5619;0 90 | 38.0446;78.0227;0 91 | 56.7822; 8.1126;0 92 | 7.5854;92.9386;0 93 | 5.3950;77.5713;0 94 | 53.0798;48.6792;0 95 | 77.9167;43.5859;0 96 | 93.4011;44.6784;0 97 | 12.9906;30.6349;0 98 | 56.8824;50.8509;0 99 | 46.9391;51.0772;0 100 | 1.1902;81.7628;0 101 | 33.7123;79.4831;0 -------------------------------------------------------------------------------- /mst_blender/mstree.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | class Node: 4 | def __init__(self, parent, pos, index, path_distance = 0.0): 5 | self.parent = parent 6 | self.pos = pos 7 | self.index = index 8 | self.children = [] 9 | self.path_distance = path_distance 10 | 11 | if parent is not None: 12 | parent.children.append(self) 13 | 14 | def mstree(points, balancing_factor = 0.5): 15 | length = len(points) 16 | dimensions = len(points[0]) 17 | 18 | closed_list = {} 19 | 20 | root_point = points[0] 21 | 22 | root_node = Node(None, root_point, 0) 23 | closed_list[0] = root_node 24 | 25 | # Init open points list 26 | open_list = [x for x in range(1,length)] 27 | 28 | # Init distance to root_point 29 | distances_squared = np.sum(np.square(points - root_point), axis = 1) 30 | distances = np.empty(length) 31 | for i in range(length - 1): 32 | distances[i] = np.sqrt(distances_squared[i]) 33 | 34 | closest_point_in_tree = np.zeros(length, dtype = int) 35 | 36 | distances = np.sqrt(distances_squared) 37 | 38 | open_distance_list = distances.copy()[1:] 39 | 40 | while len(open_distance_list) > 0: 41 | minimum_index = np.argmin(open_distance_list) 42 | minimum = open_distance_list[minimum_index] 43 | point_index = open_list.pop(minimum_index) 44 | 45 | # Get closest point and append new node to it 46 | closest_point_index = closest_point_in_tree[point_index] 47 | 48 | location = points[point_index] 49 | 50 | parent_node = closed_list[closest_point_index] 51 | actual_distance = np.sqrt(np.sum(np.square(location - parent_node.pos))) 52 | path_distance = actual_distance + parent_node.path_distance 53 | node = Node(parent_node, location, point_index, path_distance) 54 | 55 | # Add to closed list 56 | closed_list[point_index] = node 57 | # Remove from open list 58 | open_distance_list = np.delete(open_distance_list, minimum_index) 59 | 60 | open_points = points[open_list] 61 | weighted_distance = np.sqrt(np.sum(np.square(np.subtract(open_points, location)), axis = 1)) + balancing_factor * path_distance 62 | open_distance_list_indeces = np.argmin(np.column_stack((open_distance_list, weighted_distance)), axis = 1) 63 | open_distance_list = np.minimum(open_distance_list, weighted_distance) 64 | changed_values = np.zeros(len(closest_point_in_tree), dtype = bool) 65 | changed_values.put(open_list, open_distance_list_indeces) 66 | closest_point_in_tree = np.where(changed_values == 1, point_index, closest_point_in_tree) 67 | 68 | return root_node 69 | 70 | def tree_to_list(root_node): 71 | """Orders the nodes into a list recursivly using depth-first-search""" 72 | ls = [root_node] 73 | for child in root_node.children: 74 | ls.extend(tree_to_list(child)) 75 | return ls 76 | -------------------------------------------------------------------------------- /mst_blender/diameter.py: -------------------------------------------------------------------------------- 1 | from . import mstree 2 | 3 | def add_quad_diameter(root_node, scale = 0.5, offset = 0.5, path_scale = 1.0): 4 | 5 | # For realistic dendrite thickness special quadratic coefficients are needed 6 | quad_coefficients = \ 7 | {8: (0.034881, -0.6837, 3.6564), 8 | 16: (0.013947, -0.51179, 4.9629), 9 | 24: (0.0064104, -0.39213, 6.0818), 10 | 32: (0.0040126, -0.33498, 7.0306), 11 | 40: (0.0028541, -0.2992, 7.8229), 12 | 48: (0.002163, -0.27289, 8.5377), 13 | 56: (0.0017122, -0.25251, 9.1937), 14 | 64: (0.0013991, -0.23611, 9.8033), 15 | 72: (0.0011712, -0.22255, 10.375), 16 | 80: (0.0009992, -0.21109, 10.915), 17 | 88: (0.00086562, -0.20124, 11.428), 18 | 96: (0.00075942, -0.19265, 11.917), 19 | 104: (0.00067336, -0.18507, 12.386), 20 | 112: (0.00060242, -0.17833, 12.837), 21 | 120: (0.00054315, -0.17227, 13.271), 22 | 128: (0.00049301, -0.16678, 13.691), 23 | 136: (0.00045017, -0.16179, 14.097), 24 | 144: (0.00041319, -0.15722, 14.49), 25 | 152: (0.00038102, -0.15301, 14.873), 26 | 160: (0.00035283, -0.14913, 15.245), 27 | 168: (0.00032796, -0.14552, 15.608), 28 | 176: (0.00030588, -0.14216, 15.961), 29 | 184: (0.00028618, -0.13902, 16.306), 30 | 192: (0.0002685, -0.13608, 16.644), 31 | 200: (0.00025257, -0.13332, 16.974), 32 | 208: (0.00023817, -0.13071, 17.297), 33 | 216: (0.00022508, -0.12825, 17.613), 34 | 224: (0.00021315, -0.12593, 17.924), 35 | 232: (0.00020224, -0.12372, 18.228), 36 | 240: (0.00019222, -0.12162, 18.527), 37 | 248: (0.00018301, -0.11963, 18.82), 38 | 256: (0.00017452, -0.11773, 19.109), 39 | 264: (0.00016666, -0.11591, 19.392), 40 | 272: (0.00015937, -0.11418, 19.671), 41 | 280: (0.0001526, -0.11251, 19.946), 42 | 288: (0.00014629, -0.11092, 20.216), 43 | 296: (0.00014041, -0.10939, 20.482), 44 | 304: (0.00013491, -0.10793, 20.744), 45 | 312: (0.00012976, -0.10651, 21.002), 46 | 320: (0.00012493, -0.10515, 21.257), 47 | 360: (0.00010472, -0.099041, 22.48), 48 | 400: (8.9357e-05, -0.093832, 23.639), 49 | 440: (7.735e-05, -0.089314, 24.745), 50 | 480: (6.7797e-05, -0.085364, 25.793), 51 | 520: (6.0049e-05, -0.081869, 26.789), 52 | 560: (5.3664e-05, -0.078748, 27.739), 53 | 600: (4.833e-05, -0.075937, 28.647), 54 | 640: (4.382e-05, -0.073387, 29.517), 55 | 680: (3.9968e-05, -0.071061, 30.351), 56 | 720: (3.6647e-05, -0.068927, 31.152), 57 | 760: (3.3762e-05, -0.066959, 31.922), 58 | 800: (3.1236e-05, -0.065137, 32.664), 59 | 840: (2.9011e-05, -0.063444, 33.378), 60 | 880: (2.704e-05, -0.061865, 34.067), 61 | 920: (2.5283e-05, -0.060388, 34.732), 62 | 960: (2.3712e-05, -0.059003, 35.373)} 63 | 64 | # Collect all nodes in a list 65 | nodes = mstree.tree_to_list(root_node) 66 | # Determine terminal nodes 67 | terminal_nodes = [node for node in nodes if not node.children] 68 | for terminal_node in terminal_nodes: 69 | node = terminal_node 70 | c = quad_coefficients[min(quad_coefficients, key=lambda x:abs(x - terminal_node.path_distance * path_scale))] 71 | while node is not None: 72 | x = node.path_distance * path_scale 73 | if not hasattr(node, 'temp_t'): 74 | node.temp_t = [] 75 | node.temp_t.append((x**2 * c[0] + x * c[1] + c[2]) * scale) 76 | node = node.parent 77 | 78 | for node in nodes: 79 | node.thickness = sum(node.temp_t) / len(node.temp_t) + offset 80 | del node.temp_t 81 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /mst_blender/mst_blender.py: -------------------------------------------------------------------------------- 1 | from . import mstree 2 | from . import diameter 3 | import bpy 4 | import numpy as np 5 | import mathutils 6 | import math 7 | import random 8 | 9 | DENDRITE_GROUP_NAME = "DENDRITE_TREES" 10 | 11 | def buildTreeMesh(root_node, skin = False): 12 | nodes = mstree.tree_to_list(root_node) 13 | 14 | vertices = [node.pos for node in nodes] 15 | 16 | edges = [] 17 | for i, node in enumerate(nodes): 18 | if node.parent is not None: 19 | edges.append([i, nodes.index(node.parent)]) 20 | 21 | mesh = bpy.data.meshes.new("Tree") 22 | mesh.from_pydata(vertices, edges, []) 23 | mesh.update() 24 | 25 | obj = bpy.data.objects.new("Tree", mesh) 26 | bpy.context.scene.collection.objects.link(obj) 27 | 28 | if skin: 29 | obj.modifiers.new("DendriteThickness", 'SKIN') 30 | obj.modifiers["DendriteThickness"].use_smooth_shade = True 31 | for i, node in enumerate(nodes): 32 | obj.data.skin_vertices[0].data[i].radius= (node.thickness * 0.005, node.thickness * 0.005) 33 | 34 | return obj 35 | 36 | def buildTreeCurve(root_node): 37 | curve = bpy.data.curves.new('Tree', 'CURVE') 38 | curve.dimensions = '3D' 39 | 40 | nodes = mstree.tree_to_list(root_node) 41 | 42 | curve.splines.new('BEZIER') 43 | for i, node in enumerate(nodes): 44 | spline = curve.splines[-1] 45 | spline.bezier_points.add(count=1) 46 | 47 | point = spline.bezier_points[-1] 48 | point.co = mathutils.Vector(node.pos) 49 | point.handle_left_type = 'VECTOR' 50 | point.handle_right_type = 'VECTOR' 51 | 52 | if hasattr(node, 'thickness'): 53 | point.radius = node.thickness 54 | 55 | if not node.children and len(nodes) > i + 1: 56 | node = nodes[i+1].parent 57 | curve.splines.new('BEZIER') 58 | spline = curve.splines[-1] 59 | point = spline.bezier_points[0] 60 | point.co = mathutils.Vector(node.pos) 61 | point.handle_left_type = 'VECTOR' 62 | point.handle_right_type = 'VECTOR' 63 | if hasattr(node, 'thickness'): 64 | point.radius = node.thickness 65 | 66 | curve_object = bpy.data.objects.new("Tree", curve) 67 | bpy.context.scene.collection.objects.link(curve_object) 68 | 69 | curve.fill_mode = 'FULL' 70 | return curve_object 71 | 72 | def spinPoints(points, axis, axis_direction, radians = math.pi, seed = None): 73 | rng = random.Random() 74 | rng.seed(seed) 75 | 76 | dir_n = axis_direction / np.linalg.norm(axis_direction) 77 | a = axis[0]; b = axis[1]; c = axis[2] 78 | u = dir_n[0]; v = dir_n[1]; w = dir_n[2] 79 | 80 | new_points = [] 81 | 82 | # Formula: http://inside.mines.edu/fs_home/gmurray/ArbitraryAxisRotation/ 83 | for point in points: 84 | rotation = rng.random() * radians 85 | x = point[0]; y = point[1]; z = point[2] 86 | v1 = (a*(v**2 + w**2) - u*(b*v + c*w - u*x - v*y - w*z)) * (1 - np.cos(rotation)) + x*np.cos(rotation) + (-c*v + b*w - w*y + v*z) * np.sin(rotation) 87 | v2 = (b*(u**2 + w**2) - v*(a*u + c*w - u*x - v*y - w*z)) * (1 - np.cos(rotation)) + y*np.cos(rotation) + ( c*u - a*w + w*x - u*z) * np.sin(rotation) 88 | v3 = (c*(u**2 + v**2) - w*(a*u + b*v - u*x - v*y - w*z)) * (1 - np.cos(rotation)) + z*np.cos(rotation) + (-b*u + a*v - v*x + u*y) * np.sin(rotation) 89 | new_points.append((v1,v2,v3)) 90 | 91 | return np.array(new_points) 92 | 93 | def createTreeObject(options = None): 94 | if options is None: 95 | options = bpy.context.scene.mst_options 96 | 97 | # Determine from where to take points 98 | if options.point_data_type == 'PARTICLE': 99 | source_object = bpy.data.objects[options.source_object].evaluated_get(bpy.context.evaluated_depsgraph_get()) # Blender 2.80 requires this for accessing particles 100 | particle_system = source_object.particle_systems[options.source_particle_system] 101 | seed = particle_system.seed 102 | particle_points = [(x.location[0], x.location[1], x.location[2]) for x in particle_system.particles] 103 | elif options.point_data_type == 'GROUP': 104 | source_group = bpy.data.groups[options.source_group] 105 | seed = 0 106 | particle_points = [(x.location[0], x.location[1], x.location[2]) for x in source_group.objects] 107 | else: 108 | seed = 0 109 | 110 | # Get starting point from object, cursor or first particle and create numpy array from it 111 | if options.root_data_type == 'OBJECT': 112 | root_point = bpy.data.objects[options.root_data_object].location 113 | root_list = [(root_point[0], root_point[1], root_point[2])] 114 | root_list.extend(particle_points) 115 | points = np.array(root_list) - root_point 116 | elif options.root_data_type == 'CURSOR': 117 | root_point = bpy.context.scene.cursor.location 118 | root_list = [(root_point[0], root_point[1], root_point[2])] 119 | root_list.extend(particle_points) 120 | points = np.array(root_list) - root_point 121 | else: 122 | root_point = particle_points[0] 123 | points = np.array(particle_points) - root_point 124 | 125 | # Spin points randomly on an axis if enabled 126 | if options.random_spin: 127 | if options.spin_axis == 'Y': 128 | up_axis = mathutils.Vector((0.0, 1.0, 0.0)) 129 | elif options.spin_axis == 'Z': 130 | up_axis = mathutils.Vector((0.0, 0.0, 1.0)) 131 | else: 132 | up_axis = mathutils.Vector((1.0, 0.0, 0.0)) 133 | 134 | axis = bpy.data.objects[options.spin_object].rotation_euler.to_matrix() @ up_axis 135 | location = bpy.data.objects[options.spin_object].location - root_point 136 | 137 | points = spinPoints(points, np.array(location), np.array(axis), options.spin_degrees, seed) 138 | 139 | # Create the tree structure 140 | root_node = mstree.mstree(points, balancing_factor = options.balancing_factor) 141 | 142 | if options.add_thickness: 143 | # Calculate the diameter of the tree 144 | diameter.add_quad_diameter(root_node, scale = options.thickness_scale, offset = options.thickness_offset, path_scale = options.path_scale) 145 | 146 | # Build the blender object from the tree data 147 | if options.build_type == 'MESH': 148 | obj = buildTreeMesh(root_node, options.add_thickness) 149 | elif options.build_type == 'CURVE': 150 | obj = buildTreeCurve(root_node) 151 | if options.add_thickness: 152 | obj.data.bevel_depth = 0.005 153 | 154 | obj.location = root_point 155 | 156 | return obj 157 | 158 | def createMultipleTrees(points, normals, options = None): 159 | if normals is not None: 160 | if len(points) != len(normals): 161 | raise ValueError("Points and normals need to be the same length") 162 | 163 | if options is None: 164 | options = bpy.context.scene.mst_options 165 | 166 | ob = bpy.data.objects[options.source_object] 167 | 168 | particle_system = ob.particle_systems[options.source_particle_system] 169 | intial_seed = particle_system.seed 170 | 171 | objects = [] 172 | 173 | for i, point in enumerate(points): 174 | if normals is not None: 175 | normal = normals[i] 176 | else: 177 | normal = (0,0,1) 178 | 179 | particle_system.seed = intial_seed + i 180 | 181 | # Update the view layer so the particle system gets updated 182 | bpy.context.view_layer.update() 183 | 184 | obj = createTreeObject(options) 185 | 186 | obj.location = mathutils.Vector(point) 187 | 188 | obj.rotation_mode = 'QUATERNION' 189 | obj.rotation_quaternion = mathutils.Vector(normal).to_track_quat('Z', 'Y') 190 | 191 | objects.append(obj) 192 | 193 | particle_system.seed = intial_seed 194 | 195 | return objects 196 | 197 | 198 | # --- Operators --- 199 | 200 | class OBJECT_OT_mstadd(bpy.types.Operator): 201 | bl_idname = "object.add_minimum_spanning_tree" 202 | bl_label = "Add Minimum Spanning Tree" 203 | 204 | balancing_factor : bpy.props.FloatProperty(name = "Balancing factor", default = 0.5, min = 0.0, max = 1.0) 205 | 206 | point_data_type : bpy.props.EnumProperty( 207 | name = "Point data type", 208 | items = ( 209 | ('PARTICLE', 'Particle system', 'Use the particles of a particle system as points'), 210 | ('GROUP', 'Group', 'Use locations of objects in group as points') 211 | ), 212 | default = 'PARTICLE' 213 | ) 214 | 215 | source_object : bpy.props.StringProperty(name = "Object") 216 | source_particle_system : bpy.props.StringProperty(name = "Particle System") 217 | 218 | source_group : bpy.props.StringProperty(name = "Object group") 219 | 220 | root_data_type : bpy.props.EnumProperty( 221 | name = "Root data type", 222 | items = ( 223 | ('PARTICLE', 'First Particle/Object', 'Use the first particle in particle system as root point'), 224 | ('CURSOR', '3D cursor', 'Use 3D cursor location as root point'), 225 | ('OBJECT', 'Object center', 'Use an object center as root point') 226 | ), 227 | default = 'CURSOR' 228 | ) 229 | 230 | root_data_object : bpy.props.StringProperty(name = "Root object") 231 | 232 | build_type : bpy.props.EnumProperty( 233 | name = "Build type", 234 | items = ( 235 | ('MESH', 'Mesh', 'Build the tree out of vertices'), 236 | ('CURVE', 'Curve', 'Build the tree out of curves') 237 | ), 238 | default = 'MESH' 239 | ) 240 | 241 | random_spin : bpy.props.BoolProperty(name = "Random spin", default = False) 242 | spin_object : bpy.props.StringProperty(name = "Axis object") 243 | spin_degrees : bpy.props.FloatProperty(name = "Spin degrees", subtype = 'ANGLE', min = 0.0, max = 2*math.pi, default = math.pi) 244 | spin_axis : bpy.props.EnumProperty(name = "Spin axis", items = (('X', 'X', 'Spin along the X-axis of the object'), ('Y', 'Y', 'Spin along the Y-axis of the object'), ('Z', 'Z', 'Spin along the Z-axis of the object')), default = 'Y') 245 | 246 | add_thickness : bpy.props.BoolProperty(name = "Add thickness") 247 | thickness_scale : bpy.props.FloatProperty(name = "Scale", min = 0.0, default = 1.0) 248 | thickness_offset : bpy.props.FloatProperty(name = "Offset", min = 0.0, default = 0.5) 249 | path_scale : bpy.props.FloatProperty(name = "Path scale", min = 0.0, default = 100.0) 250 | 251 | def invoke(self, context, event): 252 | ao = context.active_object 253 | if ao is not None: 254 | self.source_object = ao.name 255 | self.root_data_object = ao.name 256 | self.spin_object = ao.name 257 | if len(ao.particle_systems): 258 | self.source_particle_system = context.active_object.particle_systems[0].name 259 | wm = context.window_manager 260 | return wm.invoke_props_dialog(self) 261 | 262 | def draw(self, context): 263 | op = self 264 | 265 | layout = self.layout 266 | 267 | row = layout.row() 268 | row.prop(op, "balancing_factor") 269 | 270 | row = layout.row() 271 | row.prop(op, "point_data_type") 272 | row = layout.row() 273 | 274 | if op.point_data_type == 'PARTICLE': 275 | row.prop_search(op, "source_object", bpy.data, 'objects') 276 | if op.source_object in bpy.data.objects: 277 | row = layout.row() 278 | row.prop_search(op, "source_particle_system", bpy.data.objects[op.source_object], 'particle_systems') 279 | elif op.point_data_type == 'GROUP': 280 | row.prop_search(op, "source_group", bpy.data, 'groups') 281 | 282 | row = layout.row() 283 | row.prop(op, "root_data_type") 284 | 285 | if op.root_data_type == 'OBJECT': 286 | row = layout.row() 287 | row.prop_search(op, "root_data_object", bpy.data, 'objects') 288 | 289 | row = layout.row() 290 | row.prop(op, "build_type") 291 | 292 | row = layout.row() 293 | row.prop(op, "random_spin") 294 | 295 | if op.random_spin: 296 | row = layout.row() 297 | row.prop_search(op, "spin_object", bpy.data, 'objects') 298 | 299 | row = layout.row() 300 | row.prop(op, "spin_axis") 301 | 302 | row = layout.row() 303 | row.prop(op, "spin_degrees") 304 | 305 | row = layout.row() 306 | row.prop(op, "add_thickness") 307 | 308 | if op.add_thickness: 309 | row = layout.row() 310 | row.prop(op, "thickness_scale") 311 | 312 | row = layout.row() 313 | row.prop(op, "thickness_offset") 314 | 315 | row = layout.row() 316 | row.prop(op, "path_scale") 317 | 318 | def execute(self, context): 319 | createTreeObject(self) 320 | return {'FINISHED'} 321 | 322 | class OBJECT_OT_dendriteadd(OBJECT_OT_mstadd): 323 | bl_idname = "object.add_mst_dendrites" 324 | bl_label = "Create Dendrites" 325 | 326 | balancing_factor : bpy.props.FloatProperty(name = "Balancing factor", default = 0.5, min = 0.0, max = 1.0) 327 | 328 | point_data_type : bpy.props.EnumProperty( 329 | name = "Point data type", 330 | items = ( 331 | ('PARTICLE', 'Particle system', 'Use the particles of a particle system as points'), 332 | ('GROUP', 'Group', 'Use locations of objects in group as points') 333 | ), 334 | default = 'PARTICLE' 335 | ) 336 | 337 | source_object : bpy.props.StringProperty(name = "Object") 338 | source_particle_system : bpy.props.StringProperty(name = "Particle System") 339 | 340 | source_group : bpy.props.StringProperty(name = "Object group") 341 | 342 | root_data_type : bpy.props.EnumProperty( 343 | name = "Root data type", 344 | items = ( 345 | ('PARTICLE', 'First Particle/Object', 'Use the first particle in particle system as root point'), 346 | ('CURSOR', '3D cursor', 'Use 3D cursor location as root point'), 347 | ('OBJECT', 'Object center', 'Use an object center as root point') 348 | ), 349 | default = 'CURSOR' 350 | ) 351 | 352 | root_data_object : bpy.props.StringProperty(name = "Root object") 353 | 354 | build_type : bpy.props.EnumProperty( 355 | name = "Build type", 356 | items = ( 357 | ('MESH', 'Mesh', 'Build the tree out of vertices'), 358 | ('CURVE', 'Curve', 'Build the tree out of curves') 359 | ), 360 | default = 'MESH' 361 | ) 362 | 363 | random_spin : bpy.props.BoolProperty(name = "Random spin", default = False) 364 | spin_object : bpy.props.StringProperty(name = "Axis object") 365 | spin_degrees : bpy.props.FloatProperty(name = "Spin degrees", subtype = 'ANGLE', min = 0.0, max = 2*math.pi, default = math.pi) 366 | spin_axis : bpy.props.EnumProperty(name = "Spin axis", items = (('X', 'X', 'Spin along the X-axis of the object'), ('Y', 'Y', 'Spin along the Y-axis of the object'), ('Z', 'Z', 'Spin along the Z-axis of the object')), default = 'Y') 367 | 368 | add_thickness : bpy.props.BoolProperty(name = "Add thickness") 369 | thickness_scale : bpy.props.FloatProperty(name = "Scale", min = 0.0, default = 1.0) 370 | thickness_offset : bpy.props.FloatProperty(name = "Offset", min = 0.0, default = 0.5) 371 | path_scale : bpy.props.FloatProperty(name = "Path scale", min = 0.0, default = 100.0) 372 | 373 | target_object: bpy.props.StringProperty(name = "Target object") 374 | target_particle_system: bpy.props.StringProperty(name = "Target particle system") 375 | 376 | def draw(self, context): 377 | op = self 378 | 379 | super().draw(context) 380 | 381 | layout = self.layout 382 | 383 | row = layout.row() 384 | row.prop_search(op, "target_object", bpy.data, "objects") 385 | 386 | if op.target_object in bpy.data.objects: 387 | row = layout.row() 388 | row.prop_search(op, "target_particle_system", bpy.data.objects[op.target_object], "particle_systems") 389 | 390 | 391 | def execute(self, context): 392 | options = self 393 | 394 | if DENDRITE_GROUP_NAME not in bpy.data.collections: 395 | group = bpy.data.collections.new(DENDRITE_GROUP_NAME) 396 | context.scene.collection.children.link(group) 397 | else: 398 | group = bpy.data.collections[DENDRITE_GROUP_NAME] 399 | 400 | ob = bpy.data.objects[options.target_object].evaluated_get(bpy.context.evaluated_depsgraph_get()) 401 | 402 | particle_system = ob.particle_systems[options.target_particle_system] 403 | 404 | points = [(x.location[0], x.location[1], x.location[2]) for x in particle_system.particles] 405 | 406 | normals = [bpy.data.objects[options.target_object].closest_point_on_mesh(x.location)[1] for x in particle_system.particles] 407 | 408 | trees = createMultipleTrees(points, normals, self) 409 | 410 | # Add trees to group 411 | for tree in trees: 412 | group.objects.link(tree) 413 | 414 | return {'FINISHED'} 415 | 416 | class OBJECT_OT_dendritedelete(bpy.types.Operator): 417 | bl_idname = "object.delete_mst_dendrites" 418 | bl_label = "Delete Dendrites" 419 | 420 | def execute(self, context): 421 | if DENDRITE_GROUP_NAME in bpy.data.collections: 422 | trees = bpy.data.collections[DENDRITE_GROUP_NAME].objects 423 | for tree in trees: 424 | context.scene.collection.objects.unlink(tree) 425 | bpy.data.objects.remove(tree) 426 | 427 | return {'FINISHED'} 428 | 429 | --------------------------------------------------------------------------------