File headers: use SPDX license identifiers
[blender-addons.git] / object_scatter / operator.py
blob07bf3884de1e05f21366e9c4706525c5b70bec8e
1 # SPDX-License-Identifier: GPL-2.0-or-later
3 import bpy
4 import gpu
5 import bgl
6 import blf
7 import math
8 import enum
9 import random
11 from itertools import islice
12 from mathutils.bvhtree import BVHTree
13 from mathutils import Vector, Matrix, Euler
14 from gpu_extras.batch import batch_for_shader
16 from bpy_extras.view3d_utils import (
17 region_2d_to_vector_3d,
18 region_2d_to_origin_3d
22 # Modal Operator
23 ################################################################
25 class ScatterObjects(bpy.types.Operator):
26 bl_idname = "object.scatter"
27 bl_label = "Scatter Objects"
28 bl_options = {'REGISTER', 'UNDO'}
30 @classmethod
31 def poll(cls, context):
32 return (
33 currently_in_3d_view(context)
34 and context.active_object is not None
35 and context.active_object.mode == 'OBJECT')
37 def invoke(self, context, event):
38 self.target_object = context.active_object
39 self.objects_to_scatter = get_selected_non_active_objects(context)
41 if self.target_object is None or len(self.objects_to_scatter) == 0:
42 self.report({'ERROR'}, "Select objects to scatter and a target object")
43 return {'CANCELLED'}
45 self.base_scale = get_max_object_side_length(self.objects_to_scatter)
47 self.targets = []
48 self.active_target = None
49 self.target_cache = {}
51 self.enable_draw_callback()
52 context.window_manager.modal_handler_add(self)
53 return {'RUNNING_MODAL'}
55 def modal(self, context, event):
56 context.area.tag_redraw()
58 if not event_is_in_region(event, context.region) and self.active_target is None:
59 return {'PASS_THROUGH'}
61 if event.type == 'ESC':
62 return self.finish('CANCELLED')
64 if event.type == 'RET' and event.value == 'PRESS':
65 self.create_scatter_object()
66 return self.finish('FINISHED')
68 event_used = self.handle_non_exit_event(event)
69 if event_used:
70 return {'RUNNING_MODAL'}
71 else:
72 return {'PASS_THROUGH'}
74 def handle_non_exit_event(self, event):
75 if self.active_target is None:
76 if event.type == 'LEFTMOUSE' and event.value == 'PRESS':
77 self.active_target = StrokeTarget()
78 self.active_target.start_build(self.target_object)
79 return True
80 else:
81 build_state = self.active_target.continue_build(event)
82 if build_state == BuildState.FINISHED:
83 self.targets.append(self.active_target)
84 self.active_target = None
85 self.remove_target_from_cache(self.active_target)
86 return True
88 return False
90 def enable_draw_callback(self):
91 self._draw_callback_view = bpy.types.SpaceView3D.draw_handler_add(self.draw_view, (), 'WINDOW', 'POST_VIEW')
92 self._draw_callback_px = bpy.types.SpaceView3D.draw_handler_add(self.draw_px, (), 'WINDOW', 'POST_PIXEL')
94 def disable_draw_callback(self):
95 bpy.types.SpaceView3D.draw_handler_remove(self._draw_callback_view, 'WINDOW')
96 bpy.types.SpaceView3D.draw_handler_remove(self._draw_callback_px, 'WINDOW')
98 def draw_view(self):
99 for target in self.iter_targets():
100 target.draw()
102 draw_matrices_batches(list(self.iter_matrix_batches()))
104 def draw_px(self):
105 draw_text((20, 20, 0), "Instances: " + str(len(self.get_all_matrices())))
107 def finish(self, return_value):
108 self.disable_draw_callback()
109 bpy.context.area.tag_redraw()
110 return {return_value}
112 def create_scatter_object(self):
113 matrix_chunks = make_random_chunks(
114 self.get_all_matrices(), len(self.objects_to_scatter))
116 collection = bpy.data.collections.new("Scatter")
117 bpy.context.collection.children.link(collection)
119 for obj, matrices in zip(self.objects_to_scatter, matrix_chunks):
120 make_duplicator(collection, obj, matrices)
122 def get_all_matrices(self):
123 settings = self.get_current_settings()
125 matrices = []
126 for target in self.iter_targets():
127 self.ensure_target_is_in_cache(target)
128 matrices.extend(self.target_cache[target].get_matrices(settings))
129 return matrices
131 def iter_matrix_batches(self):
132 settings = self.get_current_settings()
133 for target in self.iter_targets():
134 self.ensure_target_is_in_cache(target)
135 yield self.target_cache[target].get_batch(settings)
137 def iter_targets(self):
138 yield from self.targets
139 if self.active_target is not None:
140 yield self.active_target
142 def ensure_target_is_in_cache(self, target):
143 if target not in self.target_cache:
144 entry = TargetCacheEntry(target, self.base_scale)
145 self.target_cache[target] = entry
147 def remove_target_from_cache(self, target):
148 self.target_cache.pop(self.active_target, None)
150 def get_current_settings(self):
151 return bpy.context.scene.scatter_properties.to_settings()
153 class TargetCacheEntry:
154 def __init__(self, target, base_scale):
155 self.target = target
156 self.last_used_settings = None
157 self.base_scale = base_scale
158 self.settings_changed()
160 def get_matrices(self, settings):
161 self._handle_new_settings(settings)
162 if self.matrices is None:
163 self.matrices = self.target.get_matrices(settings)
164 return self.matrices
166 def get_batch(self, settings):
167 self._handle_new_settings(settings)
168 if self.gpu_batch is None:
169 self.gpu_batch = create_batch_for_matrices(self.get_matrices(settings), self.base_scale)
170 return self.gpu_batch
172 def _handle_new_settings(self, settings):
173 if settings != self.last_used_settings:
174 self.settings_changed()
175 self.last_used_settings = settings
177 def settings_changed(self):
178 self.matrices = None
179 self.gpu_batch = None
182 # Duplicator Creation
183 ######################################################
185 def make_duplicator(target_collection, source_object, matrices):
186 triangle_scale = 0.1
188 duplicator = triangle_object_from_matrices(source_object.name + " Duplicator", matrices, triangle_scale)
189 duplicator.instance_type = 'FACES'
190 duplicator.use_instance_faces_scale = True
191 duplicator.show_instancer_for_viewport = True
192 duplicator.show_instancer_for_render = False
193 duplicator.instance_faces_scale = 1 / triangle_scale
195 copy_obj = source_object.copy()
196 copy_obj.name = source_object.name + " - copy"
197 copy_obj.location = (0, 0, 0)
198 copy_obj.parent = duplicator
200 target_collection.objects.link(duplicator)
201 target_collection.objects.link(copy_obj)
203 def triangle_object_from_matrices(name, matrices, triangle_scale):
204 mesh = triangle_mesh_from_matrices(name, matrices, triangle_scale)
205 return bpy.data.objects.new(name, mesh)
207 def triangle_mesh_from_matrices(name, matrices, triangle_scale):
208 mesh = bpy.data.meshes.new(name)
209 vertices, polygons = mesh_data_from_matrices(matrices, triangle_scale)
210 mesh.from_pydata(vertices, [], polygons)
211 mesh.update()
212 mesh.validate()
213 return mesh
215 unit_triangle_vertices = (
216 Vector((-3**-0.25, -3**-0.75, 0)),
217 Vector((3**-0.25, -3**-0.75, 0)),
218 Vector((0, 2/3**0.75, 0)))
220 def mesh_data_from_matrices(matrices, triangle_scale):
221 vertices = []
222 polygons = []
223 triangle_vertices = [triangle_scale * v for v in unit_triangle_vertices]
225 for i, matrix in enumerate(matrices):
226 vertices.extend((matrix @ v for v in triangle_vertices))
227 polygons.append((i * 3 + 0, i * 3 + 1, i * 3 + 2))
229 return vertices, polygons
232 # Target Provider
233 #################################################
235 class BuildState(enum.Enum):
236 FINISHED = enum.auto()
237 ONGOING = enum.auto()
239 class TargetProvider:
240 def start_build(self, target_object):
241 pass
243 def continue_build(self, event):
244 return BuildState.FINISHED
246 def get_matrices(self, scatter_settings):
247 return []
249 def draw(self):
250 pass
252 class StrokeTarget(TargetProvider):
253 def start_build(self, target_object):
254 self.points = []
255 self.bvhtree = bvhtree_from_object(target_object)
256 self.batch = None
258 def continue_build(self, event):
259 if event.type == 'LEFTMOUSE' and event.value == 'RELEASE':
260 return BuildState.FINISHED
262 mouse_pos = (event.mouse_region_x, event.mouse_region_y)
263 location, *_ = shoot_region_2d_ray(self.bvhtree, mouse_pos)
264 if location is not None:
265 self.points.append(location)
266 self.batch = None
267 return BuildState.ONGOING
269 def draw(self):
270 if self.batch is None:
271 self.batch = create_line_strip_batch(self.points)
272 draw_line_strip_batch(self.batch, color=(1.0, 0.4, 0.1, 1.0), thickness=5)
274 def get_matrices(self, scatter_settings):
275 return scatter_around_stroke(self.points, self.bvhtree, scatter_settings)
277 def scatter_around_stroke(stroke_points, bvhtree, settings):
278 scattered_matrices = []
279 for point, local_seed in iter_points_on_stroke_with_seed(stroke_points, settings.density, settings.seed):
280 matrix = scatter_from_source_point(bvhtree, point, local_seed, settings)
281 scattered_matrices.append(matrix)
282 return scattered_matrices
284 def iter_points_on_stroke_with_seed(stroke_points, density, seed):
285 for i, (start, end) in enumerate(iter_pairwise(stroke_points)):
286 segment_seed = sub_seed(seed, i)
287 segment_vector = end - start
289 segment_length = segment_vector.length
290 amount = round_random(segment_length * density, segment_seed)
292 for j in range(amount):
293 t = random_uniform(sub_seed(segment_seed, j, 0))
294 origin = start + t * segment_vector
295 yield origin, sub_seed(segment_seed, j, 1)
297 def scatter_from_source_point(bvhtree, point, seed, settings):
298 # Project displaced point on surface
299 radius = random_uniform(sub_seed(seed, 0)) * settings.radius
300 offset = random_vector(sub_seed(seed, 2)) * radius
301 location, normal, *_ = bvhtree.find_nearest(point + offset)
302 assert location is not None
303 normal.normalize()
305 up_direction = normal if settings.use_normal_rotation else Vector((0, 0, 1))
307 # Scale
308 min_scale = settings.scale * (1 - settings.random_scale)
309 max_scale = settings.scale
310 scale = random_uniform(sub_seed(seed, 1), min_scale, max_scale)
312 # Location
313 location += normal * settings.normal_offset * scale
315 # Rotation
316 z_rotation = Euler((0, 0, random_uniform(sub_seed(seed, 3), 0, 2 * math.pi))).to_matrix()
317 up_rotation = up_direction.to_track_quat('Z', 'X').to_matrix()
318 local_rotation = random_euler(sub_seed(seed, 3), settings.rotation).to_matrix()
319 rotation = local_rotation @ up_rotation @ z_rotation
321 return Matrix.Translation(location) @ rotation.to_4x4() @ scale_matrix(scale)
324 # Drawing
325 #################################################
327 box_vertices = (
328 (-1, -1, 1), ( 1, -1, 1), ( 1, 1, 1), (-1, 1, 1),
329 (-1, -1, -1), ( 1, -1, -1), ( 1, 1, -1), (-1, 1, -1))
331 box_indices = (
332 (0, 1, 2), (2, 3, 0), (1, 5, 6), (6, 2, 1),
333 (7, 6, 5), (5, 4, 7), (4, 0, 3), (3, 7, 4),
334 (4, 5, 1), (1, 0, 4), (3, 2, 6), (6, 7, 3))
336 box_vertices = tuple(Vector(vertex) * 0.5 for vertex in box_vertices)
338 def draw_matrices_batches(batches):
339 shader = get_uniform_color_shader()
340 shader.bind()
341 shader.uniform_float("color", (0.4, 0.4, 1.0, 0.3))
343 bgl.glEnable(bgl.GL_BLEND)
344 bgl.glDepthMask(bgl.GL_FALSE)
346 for batch in batches:
347 batch.draw(shader)
349 bgl.glDisable(bgl.GL_BLEND)
350 bgl.glDepthMask(bgl.GL_TRUE)
352 def create_batch_for_matrices(matrices, base_scale):
353 coords = []
354 indices = []
356 scaled_box_vertices = [base_scale * vertex for vertex in box_vertices]
358 for matrix in matrices:
359 offset = len(coords)
360 coords.extend((matrix @ vertex for vertex in scaled_box_vertices))
361 indices.extend(tuple(index + offset for index in element) for element in box_indices)
363 batch = batch_for_shader(get_uniform_color_shader(),
364 'TRIS', {"pos" : coords}, indices = indices)
365 return batch
368 def draw_line_strip_batch(batch, color, thickness=1):
369 shader = get_uniform_color_shader()
370 bgl.glLineWidth(thickness)
371 shader.bind()
372 shader.uniform_float("color", color)
373 batch.draw(shader)
375 def create_line_strip_batch(coords):
376 return batch_for_shader(get_uniform_color_shader(), 'LINE_STRIP', {"pos" : coords})
379 def draw_text(location, text, size=15, color=(1, 1, 1, 1)):
380 font_id = 0
381 ui_scale = bpy.context.preferences.system.ui_scale
382 blf.position(font_id, *location)
383 blf.size(font_id, round(size * ui_scale), 72)
384 blf.draw(font_id, text)
387 # Utilities
388 ########################################################
391 Pythons random functions are designed to be used in cases
392 when a seed is set once and then many random numbers are
393 generated. To improve the user experience I want to have
394 full control over how random seeds propagate through the
395 functions. This is why I use custom random functions.
397 One benefit is that changing the object density does not
398 generate new random positions for all objects.
401 def round_random(value, seed):
402 probability = value % 1
403 if probability < random_uniform(seed):
404 return math.floor(value)
405 else:
406 return math.ceil(value)
408 def random_vector(x, min=-1, max=1):
409 return Vector((
410 random_uniform(sub_seed(x, 0), min, max),
411 random_uniform(sub_seed(x, 1), min, max),
412 random_uniform(sub_seed(x, 2), min, max)))
414 def random_euler(x, factor):
415 return Euler(tuple(random_vector(x) * factor))
417 def random_uniform(x, min=0, max=1):
418 return random_int(x) / 2147483648 * (max - min) + min
420 def random_int(x):
421 x = (x<<13) ^ x
422 return (x * (x * x * 15731 + 789221) + 1376312589) & 0x7fffffff
424 def sub_seed(seed, index, index2=0):
425 return random_int(seed * 3243 + index * 5643 + index2 * 54243)
428 def currently_in_3d_view(context):
429 return context.space_data.type == 'VIEW_3D'
431 def get_selected_non_active_objects(context):
432 return set(context.selected_objects) - {context.active_object}
434 def make_random_chunks(sequence, chunk_amount):
435 sequence = list(sequence)
436 random.shuffle(sequence)
437 return make_chunks(sequence, chunk_amount)
439 def make_chunks(sequence, chunk_amount):
440 length = math.ceil(len(sequence) / chunk_amount)
441 return [sequence[i:i+length] for i in range(0, len(sequence), length)]
443 def iter_pairwise(sequence):
444 return zip(sequence, islice(sequence, 1, None))
446 def bvhtree_from_object(object):
447 import bmesh
448 bm = bmesh.new()
450 depsgraph = bpy.context.evaluated_depsgraph_get()
451 object_eval = object.evaluated_get(depsgraph)
452 mesh = object_eval.to_mesh()
453 bm.from_mesh(mesh)
454 bm.transform(object.matrix_world)
456 bvhtree = BVHTree.FromBMesh(bm)
457 object_eval.to_mesh_clear()
458 return bvhtree
460 def shoot_region_2d_ray(bvhtree, position_2d):
461 region = bpy.context.region
462 region_3d = bpy.context.space_data.region_3d
464 origin = region_2d_to_origin_3d(region, region_3d, position_2d)
465 direction = region_2d_to_vector_3d(region, region_3d, position_2d)
467 location, normal, index, distance = bvhtree.ray_cast(origin, direction)
468 return location, normal, index, distance
470 def scale_matrix(factor):
471 m = Matrix.Identity(4)
472 m[0][0] = factor
473 m[1][1] = factor
474 m[2][2] = factor
475 return m
477 def event_is_in_region(event, region):
478 return (region.x <= event.mouse_x <= region.x + region.width
479 and region.y <= event.mouse_y <= region.y + region.height)
481 def get_max_object_side_length(objects):
482 return max(
483 max(obj.dimensions[0] for obj in objects),
484 max(obj.dimensions[1] for obj in objects),
485 max(obj.dimensions[2] for obj in objects)
488 def get_uniform_color_shader():
489 return gpu.shader.from_builtin('3D_UNIFORM_COLOR')
492 # Registration
493 ###############################################
495 def register():
496 bpy.utils.register_class(ScatterObjects)
498 def unregister():
499 bpy.utils.unregister_class(ScatterObjects)