From 54d50aec6f135236e6a5346b61be56e3f550da55 Mon Sep 17 00:00:00 2001 From: Jacques Lucke Date: Tue, 30 Oct 2018 16:29:44 +0100 Subject: New "Scatter Objects" addon This addon can distribute objects on another object. The positions of new objects are determined by custom strokes and a few settings. In the future we can support more features. For now this is mainly a replacement of the old 'Grease Scatter Objects' addon which existed in Blender 2.79. Reviewers: campbellbarton Differential Revision: https://developer.blender.org/D3787 --- object_scatter/__init__.py | 41 ++++ object_scatter/operator.py | 508 +++++++++++++++++++++++++++++++++++++++++++++ object_scatter/ui.py | 138 ++++++++++++ 3 files changed, 687 insertions(+) create mode 100644 object_scatter/__init__.py create mode 100644 object_scatter/operator.py create mode 100644 object_scatter/ui.py diff --git a/object_scatter/__init__.py b/object_scatter/__init__.py new file mode 100644 index 00000000..0d929b44 --- /dev/null +++ b/object_scatter/__init__.py @@ -0,0 +1,41 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +bl_info = { + "name": "Scatter Objects", + "author": "Jacques Lucke", + "version": (0, 1), + "blender": (2, 80, 0), + "location": "3D View", + "description": "Distribute object instances on another object.", + "warning": "", + "wiki_url": "", + "support": 'OFFICIAL', + "category": "Object", +} + +from . import ui +from . import operator + +def register(): + ui.register() + operator.register() + +def unregister(): + ui.unregister() + operator.register() diff --git a/object_scatter/operator.py b/object_scatter/operator.py new file mode 100644 index 00000000..69c2d6e4 --- /dev/null +++ b/object_scatter/operator.py @@ -0,0 +1,508 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +import bpy +import gpu +import bgl +import blf +import math +import enum +import random + +from itertools import islice +from mathutils.bvhtree import BVHTree +from mathutils import Vector, Matrix, Euler +from gpu_extras.batch import batch_for_shader + +from bpy_extras.view3d_utils import ( + region_2d_to_vector_3d, + region_2d_to_origin_3d +) + +uniform_color_shader = gpu.shader.from_builtin('3D_UNIFORM_COLOR') + + +# Modal Operator +################################################################ + +class ScatterObjects(bpy.types.Operator): + bl_idname = "object.scatter" + bl_label = "Scatter Objects" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context): + return ( + currently_in_3d_view(context) + and context.active_object is not None + and context.active_object.mode == 'OBJECT') + + def invoke(self, context, event): + self.target_object = context.active_object + self.objects_to_scatter = get_selected_non_active_objects(context) + + if self.target_object is None or len(self.objects_to_scatter) == 0: + self.report({'ERROR'}, "Select objects to scatter and a target object.") + return {'CANCELLED'} + + self.base_scale = get_max_object_side_length(self.objects_to_scatter) + + self.targets = [] + self.active_target = None + self.target_cache = {} + + self.enable_draw_callback() + context.window_manager.modal_handler_add(self) + return {'RUNNING_MODAL'} + + def modal(self, context, event): + context.area.tag_redraw() + + if not event_is_in_region(event, context.region) and self.active_target is None: + return {'PASS_THROUGH'} + + if event.type == 'ESC': + return self.finish('CANCELLED') + + if event.type == 'RET' and event.value == 'PRESS': + self.create_scatter_object() + return self.finish('FINISHED') + + event_used = self.handle_non_exit_event(event) + if event_used: + return {'RUNNING_MODAL'} + else: + return {'PASS_THROUGH'} + + def handle_non_exit_event(self, event): + if self.active_target is None: + if event.type == 'LEFTMOUSE' and event.value == 'PRESS': + self.active_target = StrokeTarget() + self.active_target.start_build(self.target_object) + return True + else: + build_state = self.active_target.continue_build(event) + if build_state == BuildState.FINISHED: + self.targets.append(self.active_target) + self.active_target = None + self.remove_target_from_cache(self.active_target) + return True + + return False + + def enable_draw_callback(self): + self._draw_callback_view = bpy.types.SpaceView3D.draw_handler_add(self.draw_view, (), 'WINDOW', 'POST_VIEW') + self._draw_callback_px = bpy.types.SpaceView3D.draw_handler_add(self.draw_px, (), 'WINDOW', 'POST_PIXEL') + + def disable_draw_callback(self): + bpy.types.SpaceView3D.draw_handler_remove(self._draw_callback_view, 'WINDOW') + bpy.types.SpaceView3D.draw_handler_remove(self._draw_callback_px, 'WINDOW') + + def draw_view(self): + for target in self.iter_targets(): + target.draw() + + draw_matrices_batches(list(self.iter_matrix_batches())) + + def draw_px(self): + draw_text((20, 20, 0), "Instances: " + str(len(self.get_all_matrices()))) + + def finish(self, return_value): + self.disable_draw_callback() + bpy.context.area.tag_redraw() + return {return_value} + + def create_scatter_object(self): + matrix_chunks = make_random_chunks( + self.get_all_matrices(), len(self.objects_to_scatter)) + + collection = bpy.data.collections.new("Scatter") + bpy.context.collection.children.link(collection) + + for obj, matrices in zip(self.objects_to_scatter, matrix_chunks): + make_duplicator(collection, obj, matrices) + + def get_all_matrices(self): + settings = self.get_current_settings() + + matrices = [] + for target in self.iter_targets(): + self.ensure_target_is_in_cache(target) + matrices.extend(self.target_cache[target].get_matrices(settings)) + return matrices + + def iter_matrix_batches(self): + settings = self.get_current_settings() + for target in self.iter_targets(): + self.ensure_target_is_in_cache(target) + yield self.target_cache[target].get_batch(settings) + + def iter_targets(self): + yield from self.targets + if self.active_target is not None: + yield self.active_target + + def ensure_target_is_in_cache(self, target): + if target not in self.target_cache: + entry = TargetCacheEntry(target, self.base_scale) + self.target_cache[target] = entry + + def remove_target_from_cache(self, target): + self.target_cache.pop(self.active_target, None) + + def get_current_settings(self): + return bpy.context.scene.scatter_properties.to_settings() + +class TargetCacheEntry: + def __init__(self, target, base_scale): + self.target = target + self.last_used_settings = None + self.base_scale = base_scale + self.settings_changed() + + def get_matrices(self, settings): + self._handle_new_settings(settings) + if self.matrices is None: + self.matrices = self.target.get_matrices(settings) + return self.matrices + + def get_batch(self, settings): + self._handle_new_settings(settings) + if self.gpu_batch is None: + self.gpu_batch = create_batch_for_matrices(self.get_matrices(settings), self.base_scale) + return self.gpu_batch + + def _handle_new_settings(self, settings): + if settings != self.last_used_settings: + self.settings_changed() + self.last_used_settings = settings + + def settings_changed(self): + self.matrices = None + self.gpu_batch = None + + +# Duplicator Creation +###################################################### + +def make_duplicator(target_collection, source_object, matrices): + triangle_scale = 0.1 + + duplicator = triangle_object_from_matrices(source_object.name + " Duplicator", matrices, triangle_scale) + duplicator.dupli_type = 'FACES' + duplicator.use_dupli_faces_scale = True + duplicator.show_duplicator_for_viewport = True + duplicator.show_duplicator_for_render = False + duplicator.dupli_faces_scale = 1 / triangle_scale + + copy_obj = source_object.copy() + copy_obj.name = source_object.name + " - copy" + copy_obj.hide_viewport = True + copy_obj.hide_render = True + copy_obj.location = (0, 0, 0) + copy_obj.parent = duplicator + + target_collection.objects.link(duplicator) + target_collection.objects.link(copy_obj) + +def triangle_object_from_matrices(name, matrices, triangle_scale): + mesh = triangle_mesh_from_matrices(name, matrices, triangle_scale) + return bpy.data.objects.new(name, mesh) + +def triangle_mesh_from_matrices(name, matrices, triangle_scale): + mesh = bpy.data.meshes.new(name) + vertices, polygons = mesh_data_from_matrices(matrices, triangle_scale) + mesh.from_pydata(vertices, [], polygons) + mesh.update() + mesh.validate() + return mesh + +unit_triangle_vertices = ( + Vector((-3**-0.25, -3**-0.75, 0)), + Vector((3**-0.25, -3**-0.75, 0)), + Vector((0, 2/3**0.75, 0))) + +def mesh_data_from_matrices(matrices, triangle_scale): + vertices = [] + polygons = [] + triangle_vertices = [triangle_scale * v for v in unit_triangle_vertices] + + for i, matrix in enumerate(matrices): + vertices.extend((matrix @ v for v in triangle_vertices)) + polygons.append((i * 3 + 0, i * 3 + 1, i * 3 + 2)) + + return vertices, polygons + + +# Target Provider +################################################# + +class BuildState(enum.Enum): + FINISHED = enum.auto() + ONGOING = enum.auto() + +class TargetProvider: + def start_build(self, target_object): + pass + + def continue_build(self, event): + return BuildState.FINISHED + + def get_matrices(self, scatter_settings): + return [] + + def draw(self): + pass + +class StrokeTarget(TargetProvider): + def start_build(self, target_object): + self.points = [] + self.bvhtree = bvhtree_from_object(target_object) + self.batch = None + + def continue_build(self, event): + if event.type == 'LEFTMOUSE' and event.value == 'RELEASE': + return BuildState.FINISHED + + mouse_pos = (event.mouse_region_x, event.mouse_region_y) + location, *_ = shoot_region_2d_ray(self.bvhtree, mouse_pos) + if location is not None: + self.points.append(location) + self.batch = None + return BuildState.ONGOING + + def draw(self): + if self.batch is None: + self.batch = create_line_strip_batch(self.points) + draw_line_strip_batch(self.batch, color=(1.0, 0.4, 0.1, 1.0), thickness=5) + + def get_matrices(self, scatter_settings): + return scatter_around_stroke(self.points, self.bvhtree, scatter_settings) + +def scatter_around_stroke(stroke_points, bvhtree, settings): + scattered_matrices = [] + for point, local_seed in iter_points_on_stroke_with_seed(stroke_points, settings.density, settings.seed): + matrix = scatter_from_source_point(bvhtree, point, local_seed, settings) + scattered_matrices.append(matrix) + return scattered_matrices + +def iter_points_on_stroke_with_seed(stroke_points, density, seed): + for i, (start, end) in enumerate(iter_pairwise(stroke_points)): + segment_seed = sub_seed(seed, i) + segment_vector = end - start + + segment_length = segment_vector.length + amount = round_random(segment_length * density, segment_seed) + + for j in range(amount): + t = random_uniform(sub_seed(segment_seed, j, 0)) + origin = start + t * segment_vector + yield origin, sub_seed(segment_seed, j, 1) + +def scatter_from_source_point(bvhtree, point, seed, settings): + # Project displaced point on surface + radius = random_uniform(sub_seed(seed, 0)) * settings.radius + offset = random_vector(sub_seed(seed, 2)) * radius + location, normal, *_ = bvhtree.find_nearest(point + offset) + assert location is not None + normal.normalize() + + # Scale + min_scale = settings.scale * (1 - settings.random_scale) + max_scale = settings.scale + scale = random_uniform(sub_seed(seed, 1), min_scale, max_scale) + + # Location + location += normal * settings.normal_offset * scale + + # Rotation + z_rotation = Euler((0, 0, random_uniform(sub_seed(seed, 3), 0, 2 * math.pi))).to_matrix() + normal_rotation = normal.to_track_quat('Z', 'X').to_matrix() + local_rotation = random_euler(sub_seed(seed, 3), settings.rotation).to_matrix() + rotation = local_rotation @ normal_rotation @ z_rotation + + return Matrix.Translation(location) @ rotation.to_4x4() @ scale_matrix(scale) + + +# Drawing +################################################# + +box_vertices = ( + (-1, -1, 1), ( 1, -1, 1), ( 1, 1, 1), (-1, 1, 1), + (-1, -1, -1), ( 1, -1, -1), ( 1, 1, -1), (-1, 1, -1)) + +box_indices = ( + (0, 1, 2), (2, 3, 0), (1, 5, 6), (6, 2, 1), + (7, 6, 5), (5, 4, 7), (4, 0, 3), (3, 7, 4), + (4, 5, 1), (1, 0, 4), (3, 2, 6), (6, 7, 3)) + +box_vertices = tuple(Vector(vertex) * 0.5 for vertex in box_vertices) + +def draw_matrices_batches(batches): + uniform_color_shader.bind() + uniform_color_shader.uniform_float("color", (0.4, 0.4, 1.0, 0.3)) + + bgl.glEnable(bgl.GL_BLEND) + bgl.glDepthMask(bgl.GL_FALSE) + + for batch in batches: + batch.draw(uniform_color_shader) + + bgl.glDisable(bgl.GL_BLEND) + bgl.glDepthMask(bgl.GL_TRUE) + +def create_batch_for_matrices(matrices, base_scale): + coords = [] + indices = [] + + scaled_box_vertices = [base_scale * vertex for vertex in box_vertices] + + for matrix in matrices: + offset = len(coords) + coords.extend((matrix @ vertex for vertex in scaled_box_vertices)) + indices.extend(tuple(index + offset for index in element) for element in box_indices) + + batch = batch_for_shader(uniform_color_shader, 'TRIS', {"pos" : coords}, indices = indices) + return batch + + +def draw_line_strip_batch(batch, color, thickness=1): + bgl.glLineWidth(thickness) + uniform_color_shader.bind() + uniform_color_shader.uniform_float("color", color) + batch.draw(uniform_color_shader) + +def create_line_strip_batch(coords): + return batch_for_shader(uniform_color_shader, 'LINE_STRIP', {"pos" : coords}) + + +def draw_text(location, text, size=15, color=(1, 1, 1, 1)): + font_id = 0 + blf.position(font_id, *location) + blf.size(font_id, size, 72) + blf.draw(font_id, text) + + +# Utilities +######################################################## + +''' +Pythons random functions are designed to be used in cases +when a seed is set once and then many random numbers are +generated. To improve the user experience I want to have +full control over how random seeds propagate through the +functions. This is why I use custom random functions. + +One benefit is that changing the object density does not +generate new random positions for all objects. +''' + +def round_random(value, seed): + probability = value % 1 + if probability < random_uniform(seed): + return math.floor(value) + else: + return math.ceil(value) + +def random_vector(x, min=-1, max=1): + return Vector(( + random_uniform(sub_seed(x, 0), min, max), + random_uniform(sub_seed(x, 1), min, max), + random_uniform(sub_seed(x, 2), min, max))) + +def random_euler(x, factor): + return Euler(tuple(random_vector(x) * factor)) + +def random_uniform(x, min=0, max=1): + return random_int(x) / 2147483648 * (max - min) + min + +def random_int(x): + x = (x<<13) ^ x + return (x * (x * x * 15731 + 789221) + 1376312589) & 0x7fffffff + +def sub_seed(seed, index, index2=0): + return random_int(seed * 3243 + index * 5643 + index2 * 54243) + + +def currently_in_3d_view(context): + return context.space_data.type == 'VIEW_3D' + +def get_selected_non_active_objects(context): + return set(context.selected_objects) - {context.active_object} + +def make_random_chunks(sequence, chunk_amount): + sequence = list(sequence) + random.shuffle(sequence) + return make_chunks(sequence, chunk_amount) + +def make_chunks(sequence, chunk_amount): + length = math.ceil(len(sequence) / chunk_amount) + return [sequence[i:i+length] for i in range(0, len(sequence), length)] + +def iter_pairwise(sequence): + return zip(sequence, islice(sequence, 1, None)) + +def bvhtree_from_object(object): + import bmesh + bm = bmesh.new() + + mesh = object.to_mesh(bpy.context.depsgraph, True) + bm.from_mesh(mesh) + bm.transform(object.matrix_world) + + bvhtree = BVHTree.FromBMesh(bm) + bpy.data.meshes.remove(mesh) + return bvhtree + +def shoot_region_2d_ray(bvhtree, position_2d): + region = bpy.context.region + region_3d = bpy.context.space_data.region_3d + + origin = region_2d_to_origin_3d(region, region_3d, position_2d) + direction = region_2d_to_vector_3d(region, region_3d, position_2d) + + location, normal, index, distance = bvhtree.ray_cast(origin, direction) + return location, normal, index, distance + +def scale_matrix(factor): + m = Matrix.Identity(4) + m[0][0] = factor + m[1][1] = factor + m[2][2] = factor + return m + +def event_is_in_region(event, region): + return (region.x <= event.mouse_x <= region.x + region.width + and region.y <= event.mouse_y <= region.y + region.height) + +def get_max_object_side_length(objects): + return max( + max(obj.dimensions[0] for obj in objects), + max(obj.dimensions[1] for obj in objects), + max(obj.dimensions[2] for obj in objects) + ) + + +# Registration +############################################### + +def register(): + bpy.utils.register_class(ScatterObjects) + +def unregister(): + bpy.utils.unregister_class(ScatterObjects) diff --git a/object_scatter/ui.py b/object_scatter/ui.py new file mode 100644 index 00000000..94d97a9c --- /dev/null +++ b/object_scatter/ui.py @@ -0,0 +1,138 @@ +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +import bpy +import math + +from collections import namedtuple + +from bpy.props import ( + IntProperty, + FloatProperty, + PointerProperty +) + + +ScatterSettings = namedtuple("ScatterSettings", + ["seed", "density", "radius", "scale", "random_scale", + "rotation", "normal_offset"]) + +class ObjectScatterProperties(bpy.types.PropertyGroup): + seed: IntProperty( + name="Seed", + default=0 + ) + + density: FloatProperty( + name="Density", + default=10, + min=0, + soft_max=50 + ) + + radius: FloatProperty( + name="Radius", + default=1, + min=0, + soft_max=5, + subtype='DISTANCE', + unit='LENGTH' + ) + + scale: FloatProperty( + name="Scale", + default=0.3, + min=0.00001, + soft_max=1 + ) + + random_scale_percentage: FloatProperty( + name="Random Scale Percentage", + default=80, + min=0, + max=100, + subtype='PERCENTAGE', + precision=0 + ) + + rotation: FloatProperty( + name="Rotation", + default=0.5, + min=0, + max=math.pi * 2, + soft_max=math.pi / 2, + subtype='ANGLE', + unit='ROTATION' + ) + + normal_offset: FloatProperty( + name="Normal Offset", + default=0, + soft_min=-1.0, + soft_max=1.0 + ) + + def to_settings(self): + return ScatterSettings( + seed=self.seed, + density=self.density, + radius=self.radius, + scale=self.scale, + random_scale=self.random_scale_percentage / 100, + rotation=self.rotation, + normal_offset=self.normal_offset, + ) + + +class ObjectScatterPanel(bpy.types.Panel): + bl_label = "Object Scatter" + bl_space_type = 'PROPERTIES' + bl_region_type = 'WINDOW' + bl_context = '.objectmode' + + def draw(self, context): + layout = self.layout + layout.use_property_split = True + scatter = context.scene.scatter_properties + + layout.prop(scatter, "density", slider=True) + layout.prop(scatter, "radius", slider=True) + + col = layout.column(align=True) + col.prop(scatter, "scale", slider=True) + col.prop(scatter, "random_scale_percentage", text="Randomness", slider=True) + + layout.prop(scatter, "rotation", slider=True) + layout.prop(scatter, "normal_offset", text="Offset", slider=True) + layout.prop(scatter, "seed") + + +classes = ( + ObjectScatterProperties, + ObjectScatterPanel, +) + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.Scene.scatter_properties = PointerProperty(type=ObjectScatterProperties) + +def unregister(): + for cls in classes: + bpy.utils.unregister_class(cls) + del bpy.types.Scene.scatter_properties -- cgit v1.2.3