forked from Lichtso/hair_guides
-
Notifications
You must be signed in to change notification settings - Fork 0
/
__init__.py
464 lines (413 loc) · 23 KB
/
__init__.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
bl_info = {
'name': 'Particle Hair from Guides',
'author': 'Alexander Meißner',
'version': (0,0,1),
'blender': (2,90,0),
'category': 'Particle',
'wiki_url': 'https://github.com/lichtso/hair_guides/',
'tracker_url': 'https://github.com/lichtso/hair_guides/issues',
'description': 'Generates hair particles from meshes (seam edges), bezier curves or nurbs surfaces.'
}
import bpy, bmesh, math
import mathutils, random
from bpy_extras import mesh_utils
from mathutils import Vector
def bisectLowerBound(key_index, a, x, low, high):
while low < high:
mid = (low+high)//2
if a[mid][key_index] < x: low = mid+1
else: high = mid
return low
def copyAttributes(dst, src):
for attribute in dir(src):
try:
setattr(dst, attribute, getattr(src, attribute))
except:
pass
def validateContext(self, context):
if context.mode != 'OBJECT':
self.report({'WARNING'}, 'Not in object mode')
return False
if not context.object:
self.report({'WARNING'}, 'No target selected as active')
return False
if not context.object.particle_systems.active:
self.report({'WARNING'}, 'Target has no active particle system')
return False
if context.object.particle_systems.active.settings.type != 'HAIR':
self.report({'WARNING'}, 'The targets active particle system is not of type "hair"')
return False
return True
def getParticleSystem(obj):
pasy = obj.particle_systems.active
pamo = None
for modifier in obj.modifiers:
if isinstance(modifier, bpy.types.ParticleSystemModifier) and modifier.particle_system == pasy:
pamo = modifier
break
return (pasy, pamo)
def beginParticleHairUpdate(context, dst_obj, hair_steps, hair_count):
pasy, pamo = getParticleSystem(dst_obj)
pamo.show_viewport = True
bpy.ops.particle.edited_clear()
pasy.settings.hair_step = hair_steps-1
pasy.settings.count = hair_count
bpy.context.scene.tool_settings.particle_edit.type = 'PARTICLES'
bpy.ops.object.mode_set(mode='PARTICLE_EDIT')
bpy.ops.wm.tool_set_by_id(name='builtin_brush.Comb')
bpy.ops.particle.brush_edit(stroke=[{'name': '', 'location': (0, 0, 0), 'mouse': (0, 0), 'pressure': 0, 'size': 0, 'pen_flip': False, 'time': 0, 'is_start': True}])
bpy.ops.particle.disconnect_hair()
depsgraph = context.evaluated_depsgraph_get()
dst_obj = dst_obj.evaluated_get(depsgraph)
pasy = dst_obj.particle_systems.active
return (dst_obj, pasy)
def finishParticleHairUpdate():
bpy.ops.particle.connect_hair()
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.particle.disconnect_hair()
class ParticleHairFromGuides(bpy.types.Operator):
bl_idname = 'particle.hair_from_guides'
bl_label = 'Particle Hair from Guides'
bl_options = {'REGISTER', 'UNDO'}
bl_description = 'Generates hair particles from meshes (seam edges), bezier curves or nurbs surfaces.'
spacing: bpy.props.FloatProperty(name='Spacing', unit='LENGTH', description='Average distance between two hairs. Decease this to increase density.', min=0.00001, default=1.0)
length_rand: bpy.props.FloatProperty(name='Length Rand', description='Randomness of hair length', min=0.0, max=1.0, default=0.0)
rand_at_root: bpy.props.FloatVectorProperty(name='Rand at Root', description='Randomness at the roots (tangent, normal)', default=(0.0, 0.0), size=2)
uniform_rand: bpy.props.FloatVectorProperty(name='Uniform Rand', description='Randomness inside the entire strand (tangent, normal)', default=(0.0, 0.0), size=2)
rand_towards_tip: bpy.props.FloatVectorProperty(name='Rand towards Tip', description='Randomness increasing towards the tip (tangent, normal)', default=(0.0, 0.0), size=2)
uniform_bias: bpy.props.FloatVectorProperty(name='Uniform Bias', description='Bias at the roots (tangent, normal)', default=(0.0, 0.0), size=2)
bias_towards_tip: bpy.props.FloatVectorProperty(name='Bias towards Tip', description='Bias increasing towards the tip (tangent, normal)', default=(0.0, 0.0), size=2)
couple_root_and_tip: bpy.props.BoolProperty(name='Couple Root & Tip', description='Couples the randomness at the root and the tip', default=False)
# added: checkboxes for no tip randomness and fill volume
no_tip_rand: bpy.props.BoolProperty(name='No Tip Rand', description='Disables Randomness at the last vertex', default=False)
fill_volume: bpy.props.BoolProperty(name='Fill Volume', description='Fill the volume with hairs', default=False)
# until here
rand_seed: bpy.props.IntProperty(name='Rand Seed', description='Increase to get a different result', default=0)
def execute(self, context):
if not validateContext(self, context):
return {'CANCELLED'}
depsgraph = context.evaluated_depsgraph_get()
dst_obj = context.object
inverse_transform = dst_obj.matrix_world.inverted()
tmp_objs = []
strands = []
hair_count = 0
hair_steps = None
dst_obj.select_set(False)
if len(context.selected_objects) == 0:
self.report({'WARNING'}, 'No source objects selected')
return {'CANCELLED'}
for src_obj in context.selected_objects:
if src_obj.type == 'CURVE' or src_obj.type == 'SURFACE':
indices = []
vertex_index = 0
if src_obj.type == 'CURVE':
if src_obj.data.bevel_depth == 0.0 and src_obj.data.extrude == 0.0:
self.report({'WARNING'}, 'Curve must have extrude or bevel depth')
return {'CANCELLED'}
resolution_u = 2 if src_obj.data.bevel_depth == 0.0 else (4 if src_obj.data.extrude == 0.0 else 3)+2*src_obj.data.bevel_resolution
for spline in src_obj.data.splines:
if spline.type != 'BEZIER':
self.report({'WARNING'}, 'Curve spline type must be Bezier')
return {'CANCELLED'}
for bezier_point in spline.bezier_points:
if bezier_point.handle_left_type == 'VECTOR' or bezier_point.handle_right_type == 'VECTOR':
self.report({'WARNING'}, 'Curve handle type must not be Vector')
return {'CANCELLED'}
resolution_v = (spline.resolution_u*(spline.point_count_u-1)+1)
indices.append((vertex_index, resolution_u-1, 1))
vertex_index += resolution_u*resolution_v
if src_obj.data.bevel_depth != 0.0 and src_obj.data.extrude != 0.0:
indices.append((vertex_index, 1, 1))
vertex_index += 2*resolution_v
indices.append((vertex_index, 1, 1))
vertex_index += 2*resolution_v
indices.append((vertex_index, resolution_u-1, 1))
vertex_index += resolution_u*resolution_v
elif src_obj.type == 'SURFACE':
for spline in src_obj.data.splines:
resolution_u = spline.resolution_u*spline.point_count_u
resolution_v = spline.resolution_v*spline.point_count_v
indices.append((vertex_index, resolution_u-1, resolution_v))
vertex_index += resolution_u*resolution_v
bpy.ops.object.select_all(action='DESELECT')
src_modifiers = []
for src_modifier in src_obj.modifiers.values():
if src_modifier.show_viewport:
src_modifiers.append(src_modifier)
src_modifier.show_viewport = False
src_obj.select_set(True)
bpy.context.view_layer.objects.active = src_obj
bpy.ops.object.convert(target='MESH', keep_original=True)
src_obj.hide_viewport = True
src_obj = context.object
tmp_objs.append(src_obj)
for src_modifier in src_modifiers:
src_modifier.show_viewport = True
dst_modifier = src_obj.modifiers.new(name=src_modifier.name, type=src_modifier.type)
copyAttributes(dst_modifier, src_modifier)
for iterator in indices:
for vertex_index in range(iterator[0], iterator[0]+iterator[1]*iterator[2]+1, iterator[2]):
src_obj.data.vertices[vertex_index].select = True
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_mode(type='VERT')
bpy.ops.mesh.mark_seam(clear=False)
bpy.ops.mesh.select_all(action='DESELECT')
bpy.ops.object.mode_set(mode='OBJECT')
elif src_obj.type == 'MESH':
src_obj.hide_viewport = True
else:
continue
mesh = bmesh.new()
mesh.from_object(src_obj, depsgraph, deform=True, cage=False, face_normals=True)
mesh.transform(inverse_transform@src_obj.matrix_world)
# added: defining important variables and counting guide hairs
average_loop_positions = []
current_guide = 0
first_edge_vertex = True
guide_count = 0
for edge in mesh.edges:
if edge.seam and len(edge.link_loops) == 1:
guide_count += 1
# until here
for edge in mesh.edges:
if edge.seam and len(edge.link_loops) == 1:
loop = edge.link_loops[0]
side_A = loop.edge.other_vert(loop.vert).co
side_B = loop.vert.co
position = (side_A+side_B)*0.5
step = (0, position, side_B-side_A, Vector(loop.face.normal))
steps = [step]
# added: append (if first vertex of guide hair) or add the fraction of that vert's position
if first_edge_vertex:
average_loop_positions.append(position / guide_count)
else:
average_loop_positions[current_guide] += (position / guide_count)
current_guide += 1
# until here
strand_hairs = max(1, round((side_A-side_B).length/self.spacing))
hair_count += strand_hairs
strands.append((strand_hairs, steps))
while True:
loop = loop.link_loop_next.link_loop_next
side_A = loop.vert.co
side_B = loop.edge.other_vert(loop.vert).co
position = (side_A+side_B)*0.5
step = (step[0]+(position-step[1]).length, position, side_B-side_A, Vector(loop.face.normal))
steps.append(step)
# added: append (if first vertex of guide hair) or add the fraction of that vert's position
if first_edge_vertex:
average_loop_positions.append(position / guide_count)
else:
average_loop_positions[current_guide] += (position / guide_count)
current_guide += 1
# until here
if len(loop.link_loops) != 1:
break
loop = loop.link_loops[0]
# added: set first_edge_vertex variable to correct value and reset current_guide
if first_edge_vertex:
first_edge_vertex = False
current_guide = 0
# until here
if hair_steps == None:
hair_steps = len(steps)
elif hair_steps != len(steps):
self.report({'WARNING'}, 'Some strands have a different number of vertices')
return {'CANCELLED'}
mesh.free()
if len(tmp_objs) > 0:
for obj in tmp_objs:
bpy.data.meshes.remove(obj.data)
if hair_steps == None:
self.report({'WARNING'}, 'Could not find any marked edges')
return {'CANCELLED'}
if hair_steps < 3:
self.report({'WARNING'}, 'Strands must be at least two faces long')
return {'CANCELLED'}
if hair_count > 10000:
self.report({'WARNING'}, 'Trying to create more than 10000 hairs, try to decrease the density')
return {'CANCELLED'}
dst_obj.select_set(True)
bpy.context.view_layer.objects.active = dst_obj
dst_obj, pasy = beginParticleHairUpdate(context, dst_obj, hair_steps, hair_count)
hair_index = 0
randomgen = random.Random()
randomgen.seed(self.rand_seed)
# added: create fixed random values for each hair, so it doesn't change position throughout the hair
loop_ran_values = []
for index in range(0, hair_count):
loop_ran_values.append(randomgen.random())
# until here
for strand in strands:
strand_hairs = strand[0]
steps = strand[1]
for index_in_strand in range(0, strand_hairs):
tangent_rand = randomgen.random()-0.5
normal_rand = randomgen.random()-0.5
tangent_offset_at_root = self.uniform_bias[0]+tangent_rand*self.rand_at_root[0]
normal_offset_at_root = self.uniform_bias[1]+normal_rand*self.rand_at_root[1]
if not self.couple_root_and_tip:
tangent_rand = randomgen.random()-0.5
normal_rand = randomgen.random()-0.5
tangent_rand_towards_tip = self.bias_towards_tip[0]+tangent_rand*self.rand_towards_tip[0]
normal_rand_towards_tip = self.bias_towards_tip[1]+normal_rand*self.rand_towards_tip[1]
length_factor = 1.0-randomgen.random()*self.length_rand
for step_index in range(0, hair_steps):
length_param = steps[step_index][0]*length_factor
remapped_step_index = bisectLowerBound(0, steps, length_param, 0, hair_steps)
step = steps[remapped_step_index]
if step_index == 0:
position = step[1]
tangent = step[2]
normal = step[3]
else:
prev_step = steps[remapped_step_index-1]
coaxial_param = (length_param-prev_step[0])/(step[0]-prev_step[0])
position = prev_step[1]+(step[1]-prev_step[1])*coaxial_param
tangent = prev_step[2]+(step[2]-prev_step[2])*coaxial_param
normal = prev_step[3]+(step[3]-prev_step[3])*coaxial_param
vertex = pasy.particles[hair_index].hair_keys[step_index]
vertex.co = position+tangent*((index_in_strand+0.5)/strand_hairs-0.5)
# added: calculating interpolated point for shorter hair and setting the actual offset to the hair (and no tip rand implementation)
if self.fill_volume:
if length_factor >= 0.999:
vertex.co += (average_loop_positions[step_index] - vertex.co) * loop_ran_values[hair_index] * loop_ran_values[hair_index]
elif step_index == 0:
vertex.co += (average_loop_positions[0] - vertex.co) * loop_ran_values[hair_index] * loop_ran_values[hair_index]
else:
total_averageline_length = 0
for index in range(0, len(average_loop_positions) - 1):
total_averageline_length += (average_loop_positions[index] - average_loop_positions[index + 1]).magnitude
averageline_length_to_vertex = 0
for index in range(0, step_index):
averageline_length_to_vertex += (average_loop_positions[index] - average_loop_positions[index + 1]).magnitude
relative_length_goal = (averageline_length_to_vertex / total_averageline_length) * length_factor
index_smaller_length = 0
smaller_length = 0
next_step_length = 0
for index in range(0, len(average_loop_positions) - 1):
next_step = (average_loop_positions[index] - average_loop_positions[index + 1]).magnitude
if (smaller_length + next_step) / total_averageline_length >= relative_length_goal:
next_step_length = next_step
break
smaller_length += next_step
index_smaller_length += 1
if index_smaller_length < len(average_loop_positions) - 1:
factor = (relative_length_goal - smaller_length / total_averageline_length) / (next_step_length / total_averageline_length)
average_point = average_loop_positions[index_smaller_length].lerp(average_loop_positions[index_smaller_length + 1], factor)
vertex.co += (average_point - vertex.co) * loop_ran_values[hair_index] * loop_ran_values[hair_index]
else:
vertex.co += (average_loop_positions[step_index] - vertex.co) * loop_ran_values[hair_index] * loop_ran_values[hair_index]
if (not self.no_tip_rand) | (step_index < hair_steps - 1):
# until here
vertex.co += tangent.normalized()*(tangent_offset_at_root+(randomgen.random()-0.5)*self.uniform_rand[0]+tangent_rand_towards_tip*length_param)
vertex.co += normal.normalized()*(normal_offset_at_root+(randomgen.random()-0.5)*self.uniform_rand[1]+normal_rand_towards_tip*length_param)
pasy.particles[hair_index].location = pasy.particles[hair_index].hair_keys[0].co
hair_index += 1
finishParticleHairUpdate()
return {'FINISHED'}
class SaveParticleHairToMesh(bpy.types.Operator):
bl_idname = 'particle.save_hair_to_mesh'
bl_label = 'Save Particle Hair to Mesh'
bl_options = {'REGISTER', 'UNDO'}
bl_description = 'Creates a mesh from active particle hair system'
def execute(self, context):
if not validateContext(self, context):
return {'CANCELLED'}
depsgraph = bpy.context.evaluated_depsgraph_get()
src_obj = context.object.evaluated_get(depsgraph)
pasy, pamo = getParticleSystem(src_obj)
steps = pasy.settings.hair_step+1
dst_name = pasy.name
mesh_data = bpy.data.meshes.new(name=dst_name)
dst_obj = bpy.data.objects.new(dst_name, mesh_data)
dst_obj.matrix_world = src_obj.matrix_world
bpy.context.scene.collection.objects.link(dst_obj)
vertices = []
edges = []
faces = []
for hair_index in range(0, len(pasy.particles)):
hair = pasy.particles[hair_index]
for step_index in range(0, steps):
if step_index > 0:
edges.append((len(vertices)-1, len(vertices)))
vertices.append(hair.hair_keys[step_index].co)
mesh_data.from_pydata(vertices, edges, faces)
mesh_data.update()
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_mode(type='VERT')
bpy.ops.mesh.select_all(action='DESELECT')
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.object.select_all(action='DESELECT')
dst_obj.select_set(True)
bpy.context.view_layer.objects.active = dst_obj
for hair_index in range(0, len(pasy.particles)):
mesh_data.vertices[hair_index*steps].select = True
return {'FINISHED'}
class RestoreParticleHairFromMesh(bpy.types.Operator):
bl_idname = 'particle.restore_hair_from_mesh'
bl_label = 'Restore Particle Hair from Mesh'
bl_options = {'REGISTER', 'UNDO'}
bl_description = 'Copies vertices of a mesh to active particle hair system'
def execute(self, context):
if not validateContext(self, context):
return {'CANCELLED'}
dst_obj = context.object
dst_obj.select_set(False)
if len(context.selected_objects) == 0:
self.report({'WARNING'}, 'No source objects selected')
return {'CANCELLED'}
hair_steps = None
hair_count = 0
for src_obj in context.selected_objects:
if src_obj.type != 'MESH':
continue
loops = mesh_utils.edge_loops_from_edges(src_obj.data)
hair_count += len(loops)
for loop in loops:
begin_is_selected = src_obj.data.vertices[loop[0]].select
end_is_selected = src_obj.data.vertices[loop[-1]].select
if begin_is_selected == end_is_selected:
self.report({'WARNING'}, 'The hair roots must be selected and the tips deselected')
return {'CANCELLED'}
if hair_steps == None:
hair_steps = len(loop)
elif hair_steps != len(loop):
self.report({'WARNING'}, 'Some hairs have a different number of vertices')
return {'CANCELLED'}
dst_obj, pasy = beginParticleHairUpdate(context, dst_obj, hair_steps, hair_count)
hair_index = 0
inverse_transform = dst_obj.matrix_world.inverted()
for src_obj in context.selected_objects:
if src_obj.type != 'MESH':
continue
loops = mesh_utils.edge_loops_from_edges(src_obj.data)
transform = inverse_transform@src_obj.matrix_world
for loop in loops:
if not src_obj.data.vertices[loop[0]].select:
loop = list(reversed(loop))
hair = pasy.particles[hair_index]
hair_index += 1
for step_index in range(0, hair_steps):
hair.hair_keys[step_index].co = transform@src_obj.data.vertices[loop[step_index]].co
finishParticleHairUpdate()
return {'FINISHED'}
operators = [ParticleHairFromGuides, SaveParticleHairToMesh, RestoreParticleHairFromMesh]
class VIEW3D_MT_object_hair_guides(bpy.types.Menu):
bl_label = 'Hair Guides'
def draw(self, context):
for operator in operators:
self.layout.operator(operator.bl_idname)
classes = operators+[VIEW3D_MT_object_hair_guides]
def menu_object_hair_guides(self, context):
self.layout.separator()
self.layout.menu('VIEW3D_MT_object_hair_guides')
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.VIEW3D_MT_object.append(menu_object_hair_guides)
def unregister():
for cls in classes:
bpy.utils.unregister_class(cls)
bpy.types.VIEW3D_MT_object.remove(menu_object_hair_guides)