diff --git a/README.md b/README.md index b5e88ce..00ac33f 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,10 @@ To open an external Python file: ![A screenshot of the top of the Text Editor, with the Auto Resolve option checked](resources/auto_resolve.png) +5. *(Optional)* Enable *Text* > *Live Edit* to automatically rebuild the Geometry Node tree every time the file is changed. + +![A screenshot of the top of the Text Editor, with the Live Edit option checked](resources/live_edit.png) + ### Documentation Documentation and typeshed files are automatically generated when you install the add-on. You can find instructions for using them with your IDE in the add-on preferences. diff --git a/__init__.py b/__init__.py index a0466d7..912bb81 100644 --- a/__init__.py +++ b/__init__.py @@ -17,6 +17,7 @@ import webbrowser from .api.tree import * from .preferences import GeometryScriptPreferences +from .absolute_path import absolute_path # Set the `geometry_script` module to the current module in case the folder is named differently. import sys @@ -49,7 +50,7 @@ class OpenDocumentation(bpy.types.Operator): bl_label = "Open Documentation" def execute(self, context): - webbrowser.open('file://' + os.path.join(os.path.dirname(__file__), 'docs/documentation.html')) + webbrowser.open('file://' + absolute_path('docs/documentation.html')) return {'FINISHED'} class GeometryScriptSettings(bpy.types.PropertyGroup): @@ -75,13 +76,18 @@ def editor_header_draw(self, context): def auto_resolve(): if bpy.context.scene.geometry_script_settings.auto_resolve: - for area in bpy.context.screen.areas: - for space in area.spaces: - if space.type == 'NODE_EDITOR': - with bpy.context.temp_override(area=area, space=space): - text = bpy.context.space_data.text - if text and text.is_modified: - bpy.ops.text.resolve_conflict(resolution='RELOAD') + try: + for area in bpy.context.screen.areas: + for space in area.spaces: + if space.type == 'TEXT_EDITOR': + with bpy.context.temp_override(area=area, space=space): + text = bpy.context.space_data.text + if text and text.is_modified: + bpy.ops.text.resolve_conflict(resolution='RELOAD') + if bpy.context.space_data.use_live_edit: + bpy.ops.text.run_script() + except: + pass return 1 def register(): diff --git a/absolute_path.py b/absolute_path.py new file mode 100644 index 0000000..2079124 --- /dev/null +++ b/absolute_path.py @@ -0,0 +1,9 @@ +import os + +def absolute_path(component): + """ + Returns the absolute path to a file in the addon directory. + + Alternative to `os.abspath` that works the same on macOS and Windows. + """ + return os.path.join(os.path.dirname(os.path.realpath(__file__)), component) \ No newline at end of file diff --git a/api/node_mapper.py b/api/node_mapper.py index 9e2e53f..8950df2 100644 --- a/api/node_mapper.py +++ b/api/node_mapper.py @@ -1,7 +1,8 @@ import bpy -from bpy.types import GeometryNodeCurveToMesh +import bl_ui from .state import State from .types import * +from ..absolute_path import absolute_path class OutputsList(dict): __getattr__ = dict.get @@ -14,7 +15,7 @@ def build_node(node_type): if _primary_arg is not None: State.current_node_tree.links.new(_primary_arg._socket, node.inputs[0]) for prop in node.bl_rna.properties: - argname = prop.name.lower().replace(' ', '_') + argname = prop.identifier.lower().replace(' ', '_') if argname in kwargs: setattr(node, prop.identifier, kwargs[argname]) for node_input in (node.inputs[1:] if _primary_arg is not None else node.inputs): @@ -58,10 +59,29 @@ def register_node(node_type, category_path=None): registered_nodes.add(node_type) for category_name in list(filter(lambda x: x.startswith('NODE_MT_category_GEO_'), dir(bpy.types))): category = getattr(bpy.types, category_name) - category_path = category.category.name.lower().replace(' ', '_') - for node in category.category.items(None): - node_type = getattr(bpy.types, node.nodetype) - register_node(node_type, category_path) + if not hasattr(category, 'category'): + category_path = category.bl_label.lower().replace(' ', '_') + add_node_type = bl_ui.node_add_menu.add_node_type + draw_node_group_add_menu = bl_ui.node_add_menu.draw_node_group_add_menu + draw_assets_for_catalog = bl_ui.node_add_menu.draw_assets_for_catalog + bl_ui.node_add_menu.add_node_type = lambda _layout, node_type_name: register_node(getattr(bpy.types, node_type_name), category_path) + bl_ui.node_add_menu.draw_node_group_add_menu = lambda _context, _layout: None + bl_ui.node_add_menu.draw_assets_for_catalog = lambda _context, _layout: None + class CategoryStub: + bl_label = "" + def __init__(self): + self.layout = Layout() + class Layout: + def separator(self): pass + category.draw(CategoryStub(), None) + bl_ui.node_add_menu.add_node_type = add_node_type + bl_ui.node_add_menu.draw_node_group_add_menu = draw_node_group_add_menu + bl_ui.node_add_menu.draw_assets_for_catalog = draw_assets_for_catalog + else: + category_path = category.category.name.lower().replace(' ', '_') + for node in category.category.items(None): + node_type = getattr(bpy.types, node.nodetype) + register_node(node_type, category_path) for node_type_name in list(filter(lambda x: 'GeometryNode' in x, dir(bpy.types))): node_type = getattr(bpy.types, node_type_name) if issubclass(node_type, bpy.types.GeometryNode): @@ -200,9 +220,9 @@ def create_documentation(): """ - with open('docs/documentation.html', 'w') as f: + with open(absolute_path('docs/documentation.html'), 'w') as f: f.write(html) - with open('typeshed/geometry_script.pyi', 'w') as fpyi, open('typeshed/geometry_script.py', 'w') as fpy: + with open(absolute_path('typeshed/geometry_script.pyi'), 'w') as fpyi, open(absolute_path('typeshed/geometry_script.py'), 'w') as fpy: newline = '\n' def type_symbol(t): return f"class {t.__name__}(Type): pass" diff --git a/api/tree.py b/api/tree.py index 4da5ed5..7d5fea5 100644 --- a/api/tree.py +++ b/api/tree.py @@ -1,6 +1,5 @@ import bpy -import re -from inspect import getfullargspec +import inspect try: import node_arrange as node_arrange except: @@ -18,6 +17,8 @@ def _as_iterable(x): def tree(name): tree_name = name def build_tree(builder): + signature = inspect.signature(builder) + # Locate or create the node group node_group = None if tree_name in bpy.data.node_groups: @@ -27,8 +28,8 @@ def tree(name): # Clear the node group before building for node in node_group.nodes: node_group.nodes.remove(node) - for group_input in node_group.inputs: - node_group.inputs.remove(group_input) + while len(node_group.inputs) > len(signature.parameters): + node_group.inputs.remove(node_group.inputs[-1]) for group_output in node_group.outputs: node_group.outputs.remove(group_output) @@ -37,21 +38,31 @@ def tree(name): group_output_node = node_group.nodes.new('NodeGroupOutput') # Collect the inputs - argspec = getfullargspec(builder) inputs = {} - for arg in argspec.args: - if not arg in argspec.annotations: - raise Exception(f"Tree input '{arg}' has no type specified. Please specify a valid NodeInput subclass.") - type_annotation = argspec.annotations[arg] - if not issubclass(type_annotation, Type): - raise Exception(f"Type of tree input '{arg}' is not a valid 'Type' subclass.") - inputs[arg] = type_annotation + for param in signature.parameters.values(): + if param.annotation == inspect.Parameter.empty: + raise Exception(f"Tree input '{param.name}' has no type specified. Please annotate with a valid node input type.") + if not issubclass(param.annotation, Type): + raise Exception(f"Type of tree input '{param.name}' is not a valid 'Type' subclass.") + inputs[param.name] = (param.annotation, param.default) # Create the input sockets and collect input values. + for i, node_input in enumerate(node_group.inputs): + if node_input.bl_socket_idname != list(inputs.values())[i][0].socket_type: + for ni in node_group.inputs: + node_group.inputs.remove(ni) + break builder_inputs = [] for i, arg in enumerate(inputs.items()): - node_group.inputs.new(arg[1].socket_type, re.sub('([A-Z])', r' \1', arg[0]).title()) - builder_inputs.append(arg[1](group_input_node.outputs[i])) + input_name = arg[0].replace('_', ' ').title() + if len(node_group.inputs) > i: + node_group.inputs[i].name = input_name + node_input = node_group.inputs[i] + else: + node_input = node_group.inputs.new(arg[1][0].socket_type, input_name) + if arg[1][1] != inspect.Parameter.empty: + node_input.default_value = arg[1][1] + builder_inputs.append(arg[1][0](group_input_node.outputs[i])) # Run the builder function State.current_node_tree = node_group diff --git a/examples/Jellyfish.py b/examples/Jellyfish.py deleted file mode 100644 index 81f0335..0000000 --- a/examples/Jellyfish.py +++ /dev/null @@ -1,13 +0,0 @@ -from geometry_script import * - -@tree("Jellyfish") -def jellyfish(geometry: Geometry, head_radius: Float): - curve_points = geometry.curve_to_points(mode='EVALUATED').points - for i, points in curve_points: - return instance_on_points() - head = ico_sphere(radius=head_radius).transform( - translation=head_transform.position, - rotation=rotate_euler(space='LOCAL', rotation=align_euler_to_vector(vector=head_transform.tangent), rotate_by=(90, 0, 0)), - scale=(1, 1, 0.5) - ) - return join_geometry(geometry=[head, geometry]) \ No newline at end of file diff --git a/examples/Mesh to LEGO.py b/examples/Mesh to LEGO.py new file mode 100644 index 0000000..a2053b1 --- /dev/null +++ b/examples/Mesh to LEGO.py @@ -0,0 +1,27 @@ +# NOTE: This example requires Blender 3.4+ + +from geometry_script import * + +@tree("LEGO") +def lego(size: Vector, stud_radius: Float, stud_depth: Float, count_x: Int, count_y: Int): + base = cube(size=size) + stud_shape = cylinder(fill_type='NGON', radius=stud_radius, depth=stud_depth, vertices=8).mesh + stud = stud_shape.transform(translation=combine_xyz(z=(stud_depth / 2) + (size.z / 2))) + hole = stud_shape.transform(translation=combine_xyz(z=(stud_depth / 2) - (size.z / 2))) + segment = mesh_boolean( + operation='DIFFERENCE', + mesh_1=mesh_boolean(operation='UNION', mesh_2=[base, stud]).mesh, + mesh_2=hole + ).mesh + return mesh_line(count=count_x, offset=(1, 0, 0)).instance_on_points( + instance=mesh_line(count=count_y, offset=(0, 1, 0)).instance_on_points(instance=segment) + ).realize_instances().merge_by_distance() + +@tree("Mesh to LEGO") +def mesh_to_lego(geometry: Geometry, resolution: Float=0.2): + return geometry.mesh_to_volume(interior_band_width=resolution, fill_volume=False).distribute_points_in_volume( + mode='DENSITY_GRID', + spacing=resolution + ).instance_on_points( + instance=lego(size=resolution, stud_radius=resolution / 3, stud_depth=resolution / 8, count_x=1, count_y=1) + ).realize_instances().merge_by_distance() \ No newline at end of file diff --git a/resources/auto_resolve.png b/resources/auto_resolve.png index 6a8f9c7..77c6a49 100644 Binary files a/resources/auto_resolve.png and b/resources/auto_resolve.png differ diff --git a/resources/live_edit.png b/resources/live_edit.png new file mode 100644 index 0000000..fd5cbd0 Binary files /dev/null and b/resources/live_edit.png differ