Cleanup: quiet float argument to in type warning
[blender-addons.git] / object_scatter / operator.py
blob8be78672b3bb860661adefb0ac2c512083cd4dc7
1 # SPDX-License-Identifier: GPL-2.0-or-later
3 import bpy
4 import gpu
5 import blf
6 import math
7 import enum
8 import random
10 from itertools import islice
11 from mathutils.bvhtree import BVHTree
12 from mathutils import Vector, Matrix, Euler
13 from gpu_extras.batch import batch_for_shader
15 from bpy_extras.view3d_utils import (
16 region_2d_to_vector_3d,
17 region_2d_to_origin_3d
21 # Modal Operator
22 ################################################################
24 class ScatterObjects(bpy.types.Operator):
25 bl_idname = "object.scatter"
26 bl_label = "Scatter Objects"
27 bl_options = {'REGISTER', 'UNDO'}
29 @classmethod
30 def poll(cls, context):
31 return (
32 currently_in_3d_view(context)
33 and context.active_object is not None
34 and context.active_object.mode == 'OBJECT')
36 def invoke(self, context, event):
37 self.target_object = context.active_object
38 self.objects_to_scatter = get_selected_non_active_objects(context)
40 if self.target_object is None or len(self.objects_to_scatter) == 0:
41 self.report({'ERROR'}, "Select objects to scatter and a target object")
42 return {'CANCELLED'}
44 self.base_scale = get_max_object_side_length(self.objects_to_scatter)
46 self.targets = []
47 self.active_target = None
48 self.target_cache = {}
50 self.enable_draw_callback()
51 context.window_manager.modal_handler_add(self)
52 return {'RUNNING_MODAL'}
54 def modal(self, context, event):
55 context.area.tag_redraw()
57 if not event_is_in_region(event, context.region) and self.active_target is None:
58 return {'PASS_THROUGH'}
60 if event.type == 'ESC':
61 return self.finish('CANCELLED')
63 if event.type == 'RET' and event.value == 'PRESS':
64 self.create_scatter_object()
65 return self.finish('FINISHED')
67 event_used = self.handle_non_exit_event(event)
68 if event_used:
69 return {'RUNNING_MODAL'}
70 else:
71 return {'PASS_THROUGH'}
73 def handle_non_exit_event(self, event):
74 if self.active_target is None:
75 if event.type == 'LEFTMOUSE' and event.value == 'PRESS':
76 self.active_target = StrokeTarget()
77 self.active_target.start_build(self.target_object)
78 return True
79 else:
80 build_state = self.active_target.continue_build(event)
81 if build_state == BuildState.FINISHED:
82 self.targets.append(self.active_target)
83 self.active_target = None
84 self.remove_target_from_cache(self.active_target)
85 return True
87 return False
89 def enable_draw_callback(self):
90 self._draw_callback_view = bpy.types.SpaceView3D.draw_handler_add(self.draw_view, (), 'WINDOW', 'POST_VIEW')
91 self._draw_callback_px = bpy.types.SpaceView3D.draw_handler_add(self.draw_px, (), 'WINDOW', 'POST_PIXEL')
93 def disable_draw_callback(self):
94 bpy.types.SpaceView3D.draw_handler_remove(self._draw_callback_view, 'WINDOW')
95 bpy.types.SpaceView3D.draw_handler_remove(self._draw_callback_px, 'WINDOW')
97 def draw_view(self):
98 for target in self.iter_targets():
99 target.draw()
101 draw_matrices_batches(list(self.iter_matrix_batches()))
103 def draw_px(self):
104 draw_text((20, 20, 0), "Instances: " + str(len(self.get_all_matrices())))
106 def finish(self, return_value):
107 self.disable_draw_callback()
108 bpy.context.area.tag_redraw()
109 return {return_value}
111 def create_scatter_object(self):
112 matrix_chunks = make_random_chunks(
113 self.get_all_matrices(), len(self.objects_to_scatter))
115 collection = bpy.data.collections.new("Scatter")
116 bpy.context.collection.children.link(collection)
118 for obj, matrices in zip(self.objects_to_scatter, matrix_chunks):
119 make_duplicator(collection, obj, matrices)
121 def get_all_matrices(self):
122 settings = self.get_current_settings()
124 matrices = []
125 for target in self.iter_targets():
126 self.ensure_target_is_in_cache(target)
127 matrices.extend(self.target_cache[target].get_matrices(settings))
128 return matrices
130 def iter_matrix_batches(self):
131 settings = self.get_current_settings()
132 for target in self.iter_targets():
133 self.ensure_target_is_in_cache(target)
134 yield self.target_cache[target].get_batch(settings)
136 def iter_targets(self):
137 yield from self.targets
138 if self.active_target is not None:
139 yield self.active_target
141 def ensure_target_is_in_cache(self, target):
142 if target not in self.target_cache:
143 entry = TargetCacheEntry(target, self.base_scale)
144 self.target_cache[target] = entry
146 def remove_target_from_cache(self, target):
147 self.target_cache.pop(self.active_target, None)
149 def get_current_settings(self):
150 return bpy.context.scene.scatter_properties.to_settings()
152 class TargetCacheEntry:
153 def __init__(self, target, base_scale):
154 self.target = target
155 self.last_used_settings = None
156 self.base_scale = base_scale
157 self.settings_changed()
159 def get_matrices(self, settings):
160 self._handle_new_settings(settings)
161 if self.matrices is None:
162 self.matrices = self.target.get_matrices(settings)
163 return self.matrices
165 def get_batch(self, settings):
166 self._handle_new_settings(settings)
167 if self.gpu_batch is None:
168 self.gpu_batch = create_batch_for_matrices(self.get_matrices(settings), self.base_scale)
169 return self.gpu_batch
171 def _handle_new_settings(self, settings):
172 if settings != self.last_used_settings:
173 self.settings_changed()
174 self.last_used_settings = settings
176 def settings_changed(self):
177 self.matrices = None
178 self.gpu_batch = None
181 # Duplicator Creation
182 ######################################################
184 def make_duplicator(target_collection, source_object, matrices):
185 triangle_scale = 0.1
187 duplicator = triangle_object_from_matrices(source_object.name + " Duplicator", matrices, triangle_scale)
188 duplicator.instance_type = 'FACES'
189 duplicator.use_instance_faces_scale = True
190 duplicator.show_instancer_for_viewport = True
191 duplicator.show_instancer_for_render = False
192 duplicator.instance_faces_scale = 1 / triangle_scale
194 copy_obj = source_object.copy()
195 copy_obj.name = source_object.name + " - copy"
196 copy_obj.location = (0, 0, 0)
197 copy_obj.parent = duplicator
199 target_collection.objects.link(duplicator)
200 target_collection.objects.link(copy_obj)
202 def triangle_object_from_matrices(name, matrices, triangle_scale):
203 mesh = triangle_mesh_from_matrices(name, matrices, triangle_scale)
204 return bpy.data.objects.new(name, mesh)
206 def triangle_mesh_from_matrices(name, matrices, triangle_scale):
207 mesh = bpy.data.meshes.new(name)
208 vertices, polygons = mesh_data_from_matrices(matrices, triangle_scale)
209 mesh.from_pydata(vertices, [], polygons)
210 mesh.update()
211 mesh.validate()
212 return mesh
214 unit_triangle_vertices = (
215 Vector((-3**-0.25, -3**-0.75, 0)),
216 Vector((3**-0.25, -3**-0.75, 0)),
217 Vector((0, 2/3**0.75, 0)))
219 def mesh_data_from_matrices(matrices, triangle_scale):
220 vertices = []
221 polygons = []
222 triangle_vertices = [triangle_scale * v for v in unit_triangle_vertices]
224 for i, matrix in enumerate(matrices):
225 vertices.extend((matrix @ v for v in triangle_vertices))
226 polygons.append((i * 3 + 0, i * 3 + 1, i * 3 + 2))
228 return vertices, polygons
231 # Target Provider
232 #################################################
234 class BuildState(enum.Enum):
235 FINISHED = enum.auto()
236 ONGOING = enum.auto()
238 class TargetProvider:
239 def start_build(self, target_object):
240 pass
242 def continue_build(self, event):
243 return BuildState.FINISHED
245 def get_matrices(self, scatter_settings):
246 return []
248 def draw(self):
249 pass
251 class StrokeTarget(TargetProvider):
252 def start_build(self, target_object):
253 self.points = []
254 self.bvhtree = bvhtree_from_object(target_object)
255 self.batch = None
257 def continue_build(self, event):
258 if event.type == 'LEFTMOUSE' and event.value == 'RELEASE':
259 return BuildState.FINISHED
261 mouse_pos = (event.mouse_region_x, event.mouse_region_y)
262 location, *_ = shoot_region_2d_ray(self.bvhtree, mouse_pos)
263 if location is not None:
264 self.points.append(location)
265 self.batch = None
266 return BuildState.ONGOING
268 def draw(self):
269 if self.batch is None:
270 self.batch = create_line_strip_batch(self.points)
271 draw_line_strip_batch(self.batch, color=(1.0, 0.4, 0.1, 1.0), thickness=5)
273 def get_matrices(self, scatter_settings):
274 return scatter_around_stroke(self.points, self.bvhtree, scatter_settings)
276 def scatter_around_stroke(stroke_points, bvhtree, settings):
277 scattered_matrices = []
278 for point, local_seed in iter_points_on_stroke_with_seed(stroke_points, settings.density, settings.seed):
279 matrix = scatter_from_source_point(bvhtree, point, local_seed, settings)
280 scattered_matrices.append(matrix)
281 return scattered_matrices
283 def iter_points_on_stroke_with_seed(stroke_points, density, seed):
284 for i, (start, end) in enumerate(iter_pairwise(stroke_points)):
285 segment_seed = sub_seed(seed, i)
286 segment_vector = end - start
288 segment_length = segment_vector.length
289 amount = round_random(segment_length * density, segment_seed)
291 for j in range(amount):
292 t = random_uniform(sub_seed(segment_seed, j, 0))
293 origin = start + t * segment_vector
294 yield origin, sub_seed(segment_seed, j, 1)
296 def scatter_from_source_point(bvhtree, point, seed, settings):
297 # Project displaced point on surface
298 radius = random_uniform(sub_seed(seed, 0)) * settings.radius
299 offset = random_vector(sub_seed(seed, 2)) * radius
300 location, normal, *_ = bvhtree.find_nearest(point + offset)
301 assert location is not None
302 normal.normalize()
304 up_direction = normal if settings.use_normal_rotation else Vector((0, 0, 1))
306 # Scale
307 min_scale = settings.scale * (1 - settings.random_scale)
308 max_scale = settings.scale
309 scale = random_uniform(sub_seed(seed, 1), min_scale, max_scale)
311 # Location
312 location += normal * settings.normal_offset * scale
314 # Rotation
315 z_rotation = Euler((0, 0, random_uniform(sub_seed(seed, 3), 0, 2 * math.pi))).to_matrix()
316 up_rotation = up_direction.to_track_quat('Z', 'X').to_matrix()
317 local_rotation = random_euler(sub_seed(seed, 3), settings.rotation).to_matrix()
318 rotation = local_rotation @ up_rotation @ z_rotation
320 return Matrix.Translation(location) @ rotation.to_4x4() @ scale_matrix(scale)
323 # Drawing
324 #################################################
326 box_vertices = (
327 (-1, -1, 1), ( 1, -1, 1), ( 1, 1, 1), (-1, 1, 1),
328 (-1, -1, -1), ( 1, -1, -1), ( 1, 1, -1), (-1, 1, -1))
330 box_indices = (
331 (0, 1, 2), (2, 3, 0), (1, 5, 6), (6, 2, 1),
332 (7, 6, 5), (5, 4, 7), (4, 0, 3), (3, 7, 4),
333 (4, 5, 1), (1, 0, 4), (3, 2, 6), (6, 7, 3))
335 box_vertices = tuple(Vector(vertex) * 0.5 for vertex in box_vertices)
337 def draw_matrices_batches(batches):
338 shader = get_uniform_color_shader()
339 shader.bind()
340 shader.uniform_float("color", (0.4, 0.4, 1.0, 0.3))
342 gpu.state.blend_set('ALPHA')
343 gpu.state.depth_mask_set(False)
345 for batch in batches:
346 batch.draw(shader)
348 gpu.state.blend_set('NONE')
349 gpu.state.depth_mask_set(True)
351 def create_batch_for_matrices(matrices, base_scale):
352 coords = []
353 indices = []
355 scaled_box_vertices = [base_scale * vertex for vertex in box_vertices]
357 for matrix in matrices:
358 offset = len(coords)
359 coords.extend((matrix @ vertex for vertex in scaled_box_vertices))
360 indices.extend(tuple(index + offset for index in element) for element in box_indices)
362 batch = batch_for_shader(get_uniform_color_shader(),
363 'TRIS', {"pos" : coords}, indices = indices)
364 return batch
367 def draw_line_strip_batch(batch, color, thickness=1):
368 shader = get_uniform_color_shader()
369 gpu.state.line_width_set(thickness)
370 shader.bind()
371 shader.uniform_float("color", color)
372 batch.draw(shader)
374 def create_line_strip_batch(coords):
375 return batch_for_shader(get_uniform_color_shader(), 'LINE_STRIP', {"pos" : coords})
378 def draw_text(location, text, size=15, color=(1, 1, 1, 1)):
379 font_id = 0
380 ui_scale = bpy.context.preferences.system.ui_scale
381 blf.position(font_id, *location)
382 blf.size(font_id, round(size * ui_scale), 72)
383 blf.draw(font_id, text)
386 # Utilities
387 ########################################################
390 Pythons random functions are designed to be used in cases
391 when a seed is set once and then many random numbers are
392 generated. To improve the user experience I want to have
393 full control over how random seeds propagate through the
394 functions. This is why I use custom random functions.
396 One benefit is that changing the object density does not
397 generate new random positions for all objects.
400 def round_random(value, seed):
401 probability = value % 1
402 if probability < random_uniform(seed):
403 return math.floor(value)
404 else:
405 return math.ceil(value)
407 def random_vector(x, min=-1, max=1):
408 return Vector((
409 random_uniform(sub_seed(x, 0), min, max),
410 random_uniform(sub_seed(x, 1), min, max),
411 random_uniform(sub_seed(x, 2), min, max)))
413 def random_euler(x, factor):
414 return Euler(tuple(random_vector(x) * factor))
416 def random_uniform(x, min=0, max=1):
417 return random_int(x) / 2147483648 * (max - min) + min
419 def random_int(x):
420 x = (x<<13) ^ x
421 return (x * (x * x * 15731 + 789221) + 1376312589) & 0x7fffffff
423 def sub_seed(seed, index, index2=0):
424 return random_int(seed * 3243 + index * 5643 + index2 * 54243)
427 def currently_in_3d_view(context):
428 return context.space_data.type == 'VIEW_3D'
430 def get_selected_non_active_objects(context):
431 return set(context.selected_objects) - {context.active_object}
433 def make_random_chunks(sequence, chunk_amount):
434 sequence = list(sequence)
435 random.shuffle(sequence)
436 return make_chunks(sequence, chunk_amount)
438 def make_chunks(sequence, chunk_amount):
439 length = math.ceil(len(sequence) / chunk_amount)
440 return [sequence[i:i+length] for i in range(0, len(sequence), length)]
442 def iter_pairwise(sequence):
443 return zip(sequence, islice(sequence, 1, None))
445 def bvhtree_from_object(object):
446 import bmesh
447 bm = bmesh.new()
449 depsgraph = bpy.context.evaluated_depsgraph_get()
450 object_eval = object.evaluated_get(depsgraph)
451 mesh = object_eval.to_mesh()
452 bm.from_mesh(mesh)
453 bm.transform(object.matrix_world)
455 bvhtree = BVHTree.FromBMesh(bm)
456 object_eval.to_mesh_clear()
457 return bvhtree
459 def shoot_region_2d_ray(bvhtree, position_2d):
460 region = bpy.context.region
461 region_3d = bpy.context.space_data.region_3d
463 origin = region_2d_to_origin_3d(region, region_3d, position_2d)
464 direction = region_2d_to_vector_3d(region, region_3d, position_2d)
466 location, normal, index, distance = bvhtree.ray_cast(origin, direction)
467 return location, normal, index, distance
469 def scale_matrix(factor):
470 m = Matrix.Identity(4)
471 m[0][0] = factor
472 m[1][1] = factor
473 m[2][2] = factor
474 return m
476 def event_is_in_region(event, region):
477 return (region.x <= event.mouse_x <= region.x + region.width
478 and region.y <= event.mouse_y <= region.y + region.height)
480 def get_max_object_side_length(objects):
481 return max(
482 max(obj.dimensions[0] for obj in objects),
483 max(obj.dimensions[1] for obj in objects),
484 max(obj.dimensions[2] for obj in objects)
487 def get_uniform_color_shader():
488 return gpu.shader.from_builtin('3D_UNIFORM_COLOR')
491 # Registration
492 ###############################################
494 def register():
495 bpy.utils.register_class(ScatterObjects)
497 def unregister():
498 bpy.utils.unregister_class(ScatterObjects)