Changeset View
Changeset View
Standalone View
Standalone View
object_scatter/operator.py
- This file was added.
| # ##### 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 mathutils.bvhtree import BVHTree | |||||
| from mathutils import Vector, Matrix, Euler | |||||
| from gpu_extras.batch import batch_for_shader | |||||
| from collections import defaultdict | |||||
| 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 len(get_selected_non_active_objects(context)) > 0 | |||||
| and context.active_object is not None | |||||
campbellbarton: Having poll get selected object list isn't efficient since poll often runs on redraw and this… | |||||
| 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) | |||||
| self.targets = [] | |||||
| self.current_target = None | |||||
| self.radius = 1 | |||||
| self.scale = 0.3 | |||||
| self.base_scale = get_max_object_side_length(self.objects_to_scatter) | |||||
| 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): | |||||
| return {"PASS_THROUGH"} | |||||
| if event.type == "ESC": | |||||
| return self.finish("CANCELLED") | |||||
| event_used = False | |||||
| if self.current_target is None: | |||||
| if event.type == "LEFTMOUSE" and event.value == "PRESS": | |||||
| self.current_target = StrokeTarget() | |||||
| self.current_target.start_build(self.target_object) | |||||
| else: | |||||
| event_used = True | |||||
| build_state = self.current_target.continue_build(context, event) | |||||
| if build_state == BuildState.FINISHED: | |||||
| self.targets.append(self.current_target) | |||||
| self.current_target = None | |||||
| self.target_cache.pop(self.current_target, None) | |||||
| if event.type == "RET" and event.value == "PRESS": | |||||
| self.create_scatter_object() | |||||
| return self.finish("FINISHED") | |||||
| if event_used: | |||||
| return {"RUNNING_MODAL"} | |||||
| else: | |||||
| return {"PASS_THROUGH"} | |||||
| 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): | |||||
| all_matrices = self.get_all_matrices() | |||||
| random.shuffle(all_matrices) | |||||
| matrix_chunks = make_chunks(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.current_target is not None: | |||||
| yield self.current_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 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) | |||||
| target_collection.objects.link(duplicator) | |||||
| 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(copy_obj) | |||||
| def make_chunks(sequence, n): | |||||
| length = math.ceil(len(sequence) / n) | |||||
| return [sequence[i:i+length] for i in range(0, len(sequence), length)] | |||||
| 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 | |||||
| def mesh_data_from_matrices(matrices, triangle_scale): | |||||
| unit_triangle = ( | |||||
| Vector((-3**-0.25, -3**-0.75, 0)) * triangle_scale, | |||||
| Vector((3**-0.25, -3**-0.75, 0)) * triangle_scale, | |||||
| Vector((0, 2/3**0.75, 0)) * triangle_scale | |||||
| ) | |||||
| vertices = [] | |||||
| polygons = [] | |||||
| for i, matrix in enumerate(matrices): | |||||
| for vertex in unit_triangle: | |||||
| vertices.append(matrix @ vertex) | |||||
| 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, context, 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) | |||||
| def continue_build(self, context, 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) | |||||
| return BuildState.ONGOING | |||||
| def draw(self): | |||||
| draw_line_strip(self.points, 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): | |||||
| if len(stroke_points) < 2: | |||||
| return | |||||
| last_point = stroke_points[0] | |||||
| for i, point in enumerate(stroke_points[1:]): | |||||
| segment_seed = sub_seed(seed, i) | |||||
| segment_direction = point - last_point | |||||
| segment_length = segment_direction.length | |||||
| amount = round_random(segment_length * density, segment_seed) | |||||
| for j in range(amount): | |||||
| t = random_uniform(sub_seed(segment_seed, j, 0)) | |||||
| origin = last_point + t * segment_direction | |||||
| yield origin, sub_seed(segment_seed, j, 1) | |||||
| last_point = point | |||||
| 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) | |||||
| ] | |||||
Done Inline Actionssuggest to always use tuples if the data can be immutable. campbellbarton: suggest to always use tuples if the data can be immutable. | |||||
| 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 = [Vector(vertex) * 0.5 for vertex in box_vertices] | |||||
| box_indices = [i for element in box_indices for i in element] | |||||
| 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((index + offset for index in box_indices)) | |||||
| batch = batch_for_shader(uniform_color_shader, 'TRIS', {"pos" : coords}, indices = indices) | |||||
| return batch | |||||
| def draw_line_strip(coords, color, thickness=1): | |||||
| batch = batch_for_shader(uniform_color_shader, 'LINE_STRIP', {"pos" : coords}) | |||||
| bgl.glLineWidth(thickness) | |||||
| uniform_color_shader.bind() | |||||
| uniform_color_shader.uniform_float("color", color) | |||||
| batch.draw(uniform_color_shader) | |||||
| 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 | |||||
| ######################################################## | |||||
| 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): | |||||
Not Done Inline ActionsWhy not use Pythons random functions? campbellbarton: Why not use Pythons random functions? | |||||
| 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 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) | |||||
| No newline at end of file | |||||
Having poll get selected object list isn't efficient since poll often runs on redraw and this could be 1000's of objects.
Normally these kinds of slow checks are moved into the operator.