animation_animall: return to release: T68332 T63750 e6a1dfbe53be
[blender-addons.git] / curve_assign_shapekey.py
blob3fe88475d19208cd38b5d55b25266b876215efaf
3 # This Blender add-on assigns one or more Bezier Curves as shape keys to another
4 # Bezier Curve
6 # Supported Blender Version: 2.80 Beta
8 # Copyright (C) 2019 Shrinivas Kulkarni
10 # License: MIT (https://github.com/Shriinivas/assignshapekey/blob/master/LICENSE)
13 import bpy, bmesh, bgl, gpu
14 from gpu_extras.batch import batch_for_shader
15 from bpy.props import BoolProperty, EnumProperty
16 from collections import OrderedDict
17 from mathutils import Vector
18 from math import sqrt, floor
19 from functools import cmp_to_key
22 bl_info = {
23 "name": "Assign Shape Keys",
24 "author": "Shrinivas Kulkarni",
25 "version": (1, 0, 0),
26 "location": "Properties > Active Tool and Workspace Settings > Assign Shape Keys",
27 "description": "Assigns one or more Bezier curves as shape keys to another Bezier curve",
28 "category": "Object",
29 "wiki_url": "https://github.com/Shriinivas/assignshapekey/blob/master/README.md",
30 "blender": (2, 80, 0),
33 matchList = [('vCnt', 'Vertex Count', 'Match by vertex count'),
34 ('bbArea', 'Area', 'Match by surface area of the bounding box'), \
35 ('bbHeight', 'Height', 'Match by bounding box height'), \
36 ('bbWidth', 'Width', 'Match by bounding box width'),
37 ('bbDepth', 'Depth', 'Match by bounding box depth'),
38 ('minX', 'Min X', 'Match by bounding bon Min X'),
39 ('maxX', 'Max X', 'Match by bounding bon Max X'),
40 ('minY', 'Min Y', 'Match by bounding bon Min Y'),
41 ('maxY', 'Max Y', 'Match by bounding bon Max Y'),
42 ('minZ', 'Min Z', 'Match by bounding bon Min Z'),
43 ('maxZ', 'Max Z', 'Match by bounding bon Max Z')]
45 DEF_ERR_MARGIN = 0.0001
47 def isBezier(obj):
48 return obj.type == 'CURVE' and len(obj.data.splines) > 0 \
49 and obj.data.splines[0].type == 'BEZIER'
51 #Avoid errors due to floating point conversions/comparisons
52 #TODO: return -1, 0, 1
53 def floatCmpWithMargin(float1, float2, margin = DEF_ERR_MARGIN):
54 return abs(float1 - float2) < margin
56 def vectCmpWithMargin(v1, v2, margin = DEF_ERR_MARGIN):
57 return all(floatCmpWithMargin(v1[i], v2[i], margin) for i in range(0, len(v1)))
59 class Segment():
61 #pts[0] - start, pts[1] - ctrl1, pts[2] - ctrl2, , pts[3] - end
62 def pointAtT(pts, t):
63 return pts[0] + t * (3 * (pts[1] - pts[0]) +
64 t* (3 * (pts[0] + pts[2]) - 6 * pts[1] +
65 t * (-pts[0] + 3 * (pts[1] - pts[2]) + pts[3])))
67 def getSegLenRecurs(pts, start, end, t1 = 0, t2 = 1, error = DEF_ERR_MARGIN):
68 t1_5 = (t1 + t2)/2
69 mid = Segment.pointAtT(pts, t1_5)
70 l = (end - start).length
71 l2 = (mid - start).length + (end - mid).length
72 if (l2 - l > error):
73 return (Segment.getSegLenRecurs(pts, start, mid, t1, t1_5, error) +
74 Segment.getSegLenRecurs(pts, mid, end, t1_5, t2, error))
75 return l2
77 def __init__(self, start, ctrl1, ctrl2, end):
78 self.start = start
79 self.ctrl1 = ctrl1
80 self.ctrl2 = ctrl2
81 self.end = end
82 pts = [start, ctrl1, ctrl2, end]
83 self.length = Segment.getSegLenRecurs(pts, start, end)
85 #see https://stackoverflow.com/questions/878862/drawing-part-of-a-b%c3%a9zier-curve-by-reusing-a-basic-b%c3%a9zier-curve-function/879213#879213
86 def partialSeg(self, t0, t1):
87 pts = [self.start, self.ctrl1, self.ctrl2, self.end]
89 if(t0 > t1):
90 tt = t1
91 t1 = t0
92 t0 = tt
94 #Let's make at least the line segments of predictable length :)
95 if(pts[0] == pts[1] and pts[2] == pts[3]):
96 pt0 = Vector([(1 - t0) * pts[0][i] + t0 * pts[2][i] for i in range(0, 3)])
97 pt1 = Vector([(1 - t1) * pts[0][i] + t1 * pts[2][i] for i in range(0, 3)])
98 return Segment(pt0, pt0, pt1, pt1)
100 u0 = 1.0 - t0
101 u1 = 1.0 - t1
103 qa = [pts[0][i]*u0*u0 + pts[1][i]*2*t0*u0 + pts[2][i]*t0*t0 for i in range(0, 3)]
104 qb = [pts[0][i]*u1*u1 + pts[1][i]*2*t1*u1 + pts[2][i]*t1*t1 for i in range(0, 3)]
105 qc = [pts[1][i]*u0*u0 + pts[2][i]*2*t0*u0 + pts[3][i]*t0*t0 for i in range(0, 3)]
106 qd = [pts[1][i]*u1*u1 + pts[2][i]*2*t1*u1 + pts[3][i]*t1*t1 for i in range(0, 3)]
108 pta = Vector([qa[i]*u0 + qc[i]*t0 for i in range(0, 3)])
109 ptb = Vector([qa[i]*u1 + qc[i]*t1 for i in range(0, 3)])
110 ptc = Vector([qb[i]*u0 + qd[i]*t0 for i in range(0, 3)])
111 ptd = Vector([qb[i]*u1 + qd[i]*t1 for i in range(0, 3)])
113 return Segment(pta, ptb, ptc, ptd)
115 #see https://stackoverflow.com/questions/24809978/calculating-the-bounding-box-of-cubic-bezier-curve
116 #(3 D - 9 C + 9 B - 3 A) t^2 + (6 A - 12 B + 6 C) t + 3 (B - A)
117 #pts[0] - start, pts[1] - ctrl1, pts[2] - ctrl2, , pts[3] - end
118 #TODO: Return Vectors to make world space calculations consistent
119 def bbox(self, mw = None):
120 def evalBez(AA, BB, CC, DD, t):
121 return AA * (1 - t) * (1 - t) * (1 - t) + \
122 3 * BB * t * (1 - t) * (1 - t) + \
123 3 * CC * t * t * (1 - t) + \
124 DD * t * t * t
126 A = self.start
127 B = self.ctrl1
128 C = self.ctrl2
129 D = self.end
131 if(mw != None):
132 A = mw @ A
133 B = mw @ B
134 C = mw @ C
135 D = mw @ D
137 MINXYZ = [min([A[i], D[i]]) for i in range(0, 3)]
138 MAXXYZ = [max([A[i], D[i]]) for i in range(0, 3)]
139 leftBotBack_rgtTopFront = [MINXYZ, MAXXYZ]
141 a = [3 * D[i] - 9 * C[i] + 9 * B[i] - 3 * A[i] for i in range(0, 3)]
142 b = [6 * A[i] - 12 * B[i] + 6 * C[i] for i in range(0, 3)]
143 c = [3 * (B[i] - A[i]) for i in range(0, 3)]
145 solnsxyz = []
146 for i in range(0, 3):
147 solns = []
148 if(a[i] == 0):
149 if(b[i] == 0):
150 solns.append(0)#Independent of t so lets take the starting pt
151 else:
152 solns.append(c[i] / b[i])
153 else:
154 rootFact = b[i] * b[i] - 4 * a[i] * c[i]
155 if(rootFact >=0 ):
156 #Two solutions with + and - sqrt
157 solns.append((-b[i] + sqrt(rootFact)) / (2 * a[i]))
158 solns.append((-b[i] - sqrt(rootFact)) / (2 * a[i]))
159 solnsxyz.append(solns)
161 for i, soln in enumerate(solnsxyz):
162 for j, t in enumerate(soln):
163 if(t < 1 and t > 0):
164 co = evalBez(A[i], B[i], C[i], D[i], t)
165 if(co < leftBotBack_rgtTopFront[0][i]):
166 leftBotBack_rgtTopFront[0][i] = co
167 if(co > leftBotBack_rgtTopFront[1][i]):
168 leftBotBack_rgtTopFront[1][i] = co
170 return leftBotBack_rgtTopFront
173 class Part():
174 def __init__(self, parent, segs, isClosed):
175 self.parent = parent
176 self.segs = segs
178 #use_cyclic_u
179 self.isClosed = isClosed
181 #Indicates if this should be closed based on its counterparts in other paths
182 self.toClose = isClosed
184 self.length = sum(seg.length for seg in self.segs)
185 self.bbox = None
186 self.bboxWorldSpace = None
188 def getSeg(self, idx):
189 return self.segs[idx]
191 def getSegs(self):
192 return self.segs
194 def getSegsCopy(self, start, end):
195 if(start == None):
196 start = 0
197 if(end == None):
198 end = len(self.segs)
199 return self.segs[start:end]
201 def getBBox(self, worldSpace):
202 #Avoid frequent calculations, as this will be called in compare method
203 if(not worldSpace and self.bbox != None):
204 return self.bbox
206 if(worldSpace and self.bboxWorldSpace != None):
207 return self.bboxWorldSpace
209 leftBotBack_rgtTopFront = [[None]*3,[None]*3]
211 for seg in self.segs:
213 if(worldSpace):
214 bb = seg.bbox(self.parent.curve.matrix_world)
215 else:
216 bb = seg.bbox()
218 for i in range(0, 3):
219 if (leftBotBack_rgtTopFront[0][i] == None or \
220 bb[0][i] < leftBotBack_rgtTopFront[0][i]):
221 leftBotBack_rgtTopFront[0][i] = bb[0][i]
223 for i in range(0, 3):
224 if (leftBotBack_rgtTopFront[1][i] == None or \
225 bb[1][i] > leftBotBack_rgtTopFront[1][i]):
226 leftBotBack_rgtTopFront[1][i] = bb[1][i]
228 if(worldSpace):
229 self.bboxWorldSpace = leftBotBack_rgtTopFront
230 else:
231 self.bbox = leftBotBack_rgtTopFront
233 return leftBotBack_rgtTopFront
235 #private
236 def getBBDiff(self, axisIdx, worldSpace):
237 obj = self.parent.curve
238 bbox = self.getBBox(worldSpace)
239 diff = abs(bbox[1][axisIdx] - bbox[0][axisIdx])
240 return diff
242 def getBBWidth(self, worldSpace):
243 return self.getBBDiff(0, worldSpace)
245 def getBBHeight(self, worldSpace):
246 return self.getBBDiff(1, worldSpace)
248 def getBBDepth(self, worldSpace):
249 return self.getBBDiff(2, worldSpace)
251 def bboxSurfaceArea(self, worldSpace):
252 leftBotBack_rgtTopFront = self.getBBox(worldSpace)
253 w = abs( leftBotBack_rgtTopFront[1][0] - leftBotBack_rgtTopFront[0][0] )
254 l = abs( leftBotBack_rgtTopFront[1][1] - leftBotBack_rgtTopFront[0][1] )
255 d = abs( leftBotBack_rgtTopFront[1][2] - leftBotBack_rgtTopFront[0][2] )
257 return 2 * (w * l + w * d + l * d)
259 def getSegCnt(self):
260 return len(self.segs)
262 def getBezierPtsInfo(self):
263 prevSeg = None
264 bezierPtsInfo = []
266 for j, seg in enumerate(self.getSegs()):
268 pt = seg.start
269 handleRight = seg.ctrl1
271 if(j == 0):
272 if(self.toClose):
273 handleLeft = self.getSeg(-1).ctrl2
274 else:
275 handleLeft = pt
276 else:
277 handleLeft = prevSeg.ctrl2
279 bezierPtsInfo.append([pt, handleLeft, handleRight])
280 prevSeg = seg
282 if(self.toClose == True):
283 bezierPtsInfo[-1][2] = seg.ctrl1
284 else:
285 bezierPtsInfo.append([prevSeg.end, prevSeg.ctrl2, prevSeg.end])
287 return bezierPtsInfo
289 def __repr__(self):
290 return str(self.length)
293 class Path:
294 def __init__(self, curve, objData = None, name = None):
296 if(objData == None):
297 objData = curve.data
299 if(name == None):
300 name = curve.name
302 self.name = name
303 self.curve = curve
305 self.parts = [Part(self, getSplineSegs(s), s.use_cyclic_u) for s in objData.splines]
307 def getPartCnt(self):
308 return len(self.parts)
310 def getPartView(self):
311 p = Part(self, [seg for part in self.parts for seg in part.getSegs()], None)
312 return p
314 def getPartBoundaryIdxs(self):
315 cumulCntList = set()
316 cumulCnt = 0
318 for p in self.parts:
319 cumulCnt += p.getSegCnt()
320 cumulCntList.add(cumulCnt)
322 return cumulCntList
324 def updatePartsList(self, segCntsPerPart, byPart):
325 monolithicSegList = [seg for part in self.parts for seg in part.getSegs()]
326 oldParts = self.parts[:]
327 currPart = oldParts[0]
328 partIdx = 0
329 self.parts.clear()
331 for i in range(0, len(segCntsPerPart)):
332 if( i == 0):
333 currIdx = 0
334 else:
335 currIdx = segCntsPerPart[i-1]
337 nextIdx = segCntsPerPart[i]
338 isClosed = False
340 if(vectCmpWithMargin(monolithicSegList[currIdx].start, \
341 currPart.getSegs()[0].start) and \
342 vectCmpWithMargin(monolithicSegList[nextIdx-1].end, \
343 currPart.getSegs()[-1].end)):
344 isClosed = currPart.isClosed
346 self.parts.append(Part(self, \
347 monolithicSegList[currIdx:nextIdx], isClosed))
349 if(monolithicSegList[nextIdx-1] == currPart.getSegs()[-1]):
350 partIdx += 1
351 if(partIdx < len(oldParts)):
352 currPart = oldParts[partIdx]
354 def getBezierPtsBySpline(self):
355 data = []
357 for i, part in enumerate(self.parts):
358 data.append(part.getBezierPtsInfo())
360 return data
362 def getNewCurveData(self):
364 newCurveData = self.curve.data.copy()
365 newCurveData.splines.clear()
367 splinesData = self.getBezierPtsBySpline()
369 for i, newPoints in enumerate(splinesData):
371 spline = newCurveData.splines.new('BEZIER')
372 spline.bezier_points.add(len(newPoints)-1)
373 spline.use_cyclic_u = self.parts[i].toClose
375 for j in range(0, len(spline.bezier_points)):
376 newPoint = newPoints[j]
377 spline.bezier_points[j].co = newPoint[0]
378 spline.bezier_points[j].handle_left = newPoint[1]
379 spline.bezier_points[j].handle_right = newPoint[2]
380 spline.bezier_points[j].handle_right_type = 'FREE'
382 return newCurveData
384 def updateCurve(self):
385 curveData = self.curve.data
386 #Remove existing shape keys first
387 if(curveData.shape_keys != None):
388 keyblocks = reversed(curveData.shape_keys.key_blocks)
389 for sk in keyblocks:
390 self.curve.shape_key_remove(sk)
391 self.curve.data = self.getNewCurveData()
392 bpy.data.curves.remove(curveData)
394 def main(removeOriginal, space, matchParts, matchCriteria, alignBy, alignValues):
395 targetObj = bpy.context.active_object
396 if(targetObj == None or not isBezier(targetObj)):
397 return
399 target = Path(targetObj)
401 shapekeys = [Path(c) for c in bpy.context.selected_objects if isBezier(c) \
402 and c != bpy.context.active_object]
404 if(len(shapekeys) == 0):
405 return
407 shapekeys = getExistingShapeKeyPaths(target) + shapekeys
408 userSel = [target] + shapekeys
410 for path in userSel:
411 alignPath(path, matchParts, matchCriteria, alignBy, alignValues)
413 addMissingSegs(userSel, byPart = (matchParts != "-None-"))
415 bIdxs = set()
416 for path in userSel:
417 bIdxs = bIdxs.union(path.getPartBoundaryIdxs())
419 for path in userSel:
420 path.updatePartsList(sorted(list(bIdxs)), byPart = False)
422 #All will have the same part count by now
423 allToClose = [all(path.parts[j].isClosed for path in userSel)
424 for j in range(0, len(userSel[0].parts))]
426 #All paths will have the same no of splines with the same no of bezier points
427 for path in userSel:
428 for j, part in enumerate(path.parts):
429 part.toClose = allToClose[j]
431 target.updateCurve()
433 target.curve.shape_key_add(name = 'Basis')
435 addShapeKeys(target.curve, shapekeys, space)
437 if(removeOriginal):
438 for path in userSel:
439 if(path.curve != target.curve):
440 safeRemoveCurveObj(path.curve)
442 return {}
444 def getSplineSegs(spline):
445 p = spline.bezier_points
446 segs = [Segment(p[i-1].co, p[i-1].handle_right, p[i].handle_left, p[i].co) \
447 for i in range(1, len(p))]
448 if(spline.use_cyclic_u):
449 segs.append(Segment(p[-1].co, p[-1].handle_right, p[0].handle_left, p[0].co))
450 return segs
452 def subdivideSeg(origSeg, noSegs):
453 if(noSegs < 2):
454 return [origSeg]
456 segs = []
457 oldT = 0
458 segLen = origSeg.length / noSegs
460 for i in range(0, noSegs-1):
461 t = float(i+1) / noSegs
462 seg = origSeg.partialSeg(oldT, t)
463 segs.append(seg)
464 oldT = t
466 seg = origSeg.partialSeg(oldT, 1)
467 segs.append(seg)
469 return segs
472 def getSubdivCntPerSeg(part, toAddCnt):
474 class SegWrapper:
475 def __init__(self, idx, seg):
476 self.idx = idx
477 self.seg = seg
478 self.length = seg.length
480 class PartWrapper:
481 def __init__(self, part):
482 self.segList = []
483 self.segCnt = len(part.getSegs())
484 for idx, seg in enumerate(part.getSegs()):
485 self.segList.append(SegWrapper(idx, seg))
487 partWrapper = PartWrapper(part)
488 partLen = part.length
489 avgLen = partLen / (partWrapper.segCnt + toAddCnt)
491 segsToDivide = [sr for sr in partWrapper.segList if sr.seg.length >= avgLen]
492 segToDivideCnt = len(segsToDivide)
493 avgLen = sum(sr.seg.length for sr in segsToDivide) / (segToDivideCnt + toAddCnt)
495 segsToDivide = sorted(segsToDivide, key=lambda x: x.length, reverse = True)
497 cnts = [0] * partWrapper.segCnt
498 addedCnt = 0
501 for i in range(0, segToDivideCnt):
502 segLen = segsToDivide[i].seg.length
504 divideCnt = int(round(segLen/avgLen)) - 1
505 if(divideCnt == 0):
506 break
508 if((addedCnt + divideCnt) >= toAddCnt):
509 cnts[segsToDivide[i].idx] = toAddCnt - addedCnt
510 addedCnt = toAddCnt
511 break
513 cnts[segsToDivide[i].idx] = divideCnt
515 addedCnt += divideCnt
517 #TODO: Verify if needed
518 while(toAddCnt > addedCnt):
519 for i in range(0, segToDivideCnt):
520 cnts[segsToDivide[i].idx] += 1
521 addedCnt += 1
522 if(toAddCnt == addedCnt):
523 break
525 return cnts
527 #Just distribute equally; this is likely a rare condition. So why complicate?
528 def distributeCnt(maxSegCntsByPart, startIdx, extraCnt):
529 added = 0
530 elemCnt = len(maxSegCntsByPart) - startIdx
531 cntPerElem = floor(extraCnt / elemCnt)
532 remainder = extraCnt % elemCnt
534 for i in range(startIdx, len(maxSegCntsByPart)):
535 maxSegCntsByPart[i] += cntPerElem
536 if(i < remainder + startIdx):
537 maxSegCntsByPart[i] += 1
539 #Make all the paths to have the maximum number of segments in the set
540 #TODO: Refactor
541 def addMissingSegs(selPaths, byPart):
542 maxSegCntsByPart = []
543 maxSegCnt = 0
545 resSegCnt = []
546 sortedPaths = sorted(selPaths, key = lambda c: -len(c.parts))
548 for i, path in enumerate(sortedPaths):
549 if(byPart == False):
550 segCnt = path.getPartView().getSegCnt()
551 if(segCnt > maxSegCnt):
552 maxSegCnt = segCnt
553 else:
554 resSegCnt.append([])
555 for j, part in enumerate(path.parts):
556 partSegCnt = part.getSegCnt()
557 resSegCnt[i].append(partSegCnt)
559 #First path
560 if(j == len(maxSegCntsByPart)):
561 maxSegCntsByPart.append(partSegCnt)
563 #last part of this path, but other paths in set have more parts
564 elif((j == len(path.parts) - 1) and
565 len(maxSegCntsByPart) > len(path.parts)):
567 remainingSegs = sum(maxSegCntsByPart[j:])
568 if(partSegCnt <= remainingSegs):
569 resSegCnt[i][j] = remainingSegs
570 else:
571 #This part has more segs than the sum of the remaining part segs
572 #So distribute the extra count
573 distributeCnt(maxSegCntsByPart, j, (partSegCnt - remainingSegs))
575 #Also, adjust the seg count of the last part of the previous
576 #segments that had fewer than max number of parts
577 for k in range(0, i):
578 if(len(sortedPaths[k].parts) < len(maxSegCntsByPart)):
579 totalSegs = sum(maxSegCntsByPart)
580 existingSegs = sum(maxSegCntsByPart[:len(sortedPaths[k].parts)-1])
581 resSegCnt[k][-1] = totalSegs - existingSegs
583 elif(partSegCnt > maxSegCntsByPart[j]):
584 maxSegCntsByPart[j] = partSegCnt
585 for i, path in enumerate(sortedPaths):
587 if(byPart == False):
588 partView = path.getPartView()
589 segCnt = partView.getSegCnt()
590 diff = maxSegCnt - segCnt
592 if(diff > 0):
593 cnts = getSubdivCntPerSeg(partView, diff)
594 cumulSegIdx = 0
595 for j in range(0, len(path.parts)):
596 part = path.parts[j]
597 newSegs = []
598 for k, seg in enumerate(part.getSegs()):
599 numSubdivs = cnts[cumulSegIdx] + 1
600 newSegs += subdivideSeg(seg, numSubdivs)
601 cumulSegIdx += 1
603 path.parts[j] = Part(path, newSegs, part.isClosed)
604 else:
605 for j in range(0, len(path.parts)):
606 part = path.parts[j]
607 newSegs = []
609 partSegCnt = part.getSegCnt()
611 #TODO: Adding everything in the last part?
612 if(j == (len(path.parts)-1) and
613 len(maxSegCntsByPart) > len(path.parts)):
614 diff = resSegCnt[i][j] - partSegCnt
615 else:
616 diff = maxSegCntsByPart[j] - partSegCnt
618 if(diff > 0):
619 cnts = getSubdivCntPerSeg(part, diff)
621 for k, seg in enumerate(part.getSegs()):
622 seg = part.getSeg(k)
623 subdivCnt = cnts[k] + 1 #1 for the existing one
624 newSegs += subdivideSeg(seg, subdivCnt)
626 #isClosed won't be used, but let's update anyway
627 path.parts[j] = Part(path, newSegs, part.isClosed)
629 #TODO: Simplify (Not very readable)
630 def alignPath(path, matchParts, matchCriteria, alignBy, alignValues):
632 parts = path.parts[:]
634 if(matchParts == 'custom'):
635 fnMap = {'vCnt' : lambda part: -1 * part.getSegCnt(), \
636 'bbArea': lambda part: -1 * part.bboxSurfaceArea(worldSpace = True), \
637 'bbHeight' : lambda part: -1 * part.getBBHeight(worldSpace = True), \
638 'bbWidth' : lambda part: -1 * part.getBBWidth(worldSpace = True), \
639 'bbDepth' : lambda part: -1 * part.getBBDepth(worldSpace = True)
641 matchPartCmpFns = []
642 for criterion in matchCriteria:
643 fn = fnMap.get(criterion)
644 if(fn == None):
645 minmax = criterion[:3] == 'max' #0 if min; 1 if max
646 axisIdx = ord(criterion[3:]) - ord('X')
648 fn = eval('lambda part: part.getBBox(worldSpace = True)[' + \
649 str(minmax) + '][' + str(axisIdx) + ']')
651 matchPartCmpFns.append(fn)
653 def comparer(left, right):
654 for fn in matchPartCmpFns:
655 a = fn(left)
656 b = fn(right)
658 if(floatCmpWithMargin(a, b)):
659 continue
660 else:
661 return (a > b) - ( a < b) #No cmp in python3
663 return 0
665 parts = sorted(parts, key = cmp_to_key(comparer))
667 alignCmpFn = None
668 if(alignBy == 'vertCo'):
669 def evalCmp(criteria, pt1, pt2):
670 if(len(criteria) == 0):
671 return True
673 minmax = criteria[0][0]
674 axisIdx = criteria[0][1]
675 val1 = pt1[axisIdx]
676 val2 = pt2[axisIdx]
678 if(floatCmpWithMargin(val1, val2)):
679 criteria = criteria[:]
680 criteria.pop(0)
681 return evalCmp(criteria, pt1, pt2)
683 return val1 < val2 if minmax == 'min' else val1 > val2
685 alignCri = [[a[:3], ord(a[3:]) - ord('X')] for a in alignValues]
686 alignCmpFn = lambda pt1, pt2, curve: (evalCmp(alignCri, \
687 curve.matrix_world @ pt1, curve.matrix_world @ pt2))
689 startPt = None
690 startIdx = None
692 for i in range(0, len(parts)):
693 #Only truly closed parts
694 if(alignCmpFn != None and parts[i].isClosed):
695 for j in range(0, parts[i].getSegCnt()):
696 seg = parts[i].getSeg(j)
697 if(j == 0 or alignCmpFn(seg.start, startPt, path.curve)):
698 startPt = seg.start
699 startIdx = j
701 path.parts[i]= Part(path, parts[i].getSegsCopy(startIdx, None) + \
702 parts[i].getSegsCopy(None, startIdx), parts[i].isClosed)
703 else:
704 path.parts[i] = parts[i]
706 #TODO: Other shape key attributes like interpolation...?
707 def getExistingShapeKeyPaths(path):
708 obj = path.curve
709 paths = []
711 if(obj.data.shape_keys != None):
712 keyblocks = obj.data.shape_keys.key_blocks[1:]#Skip basis
713 for key in keyblocks:
714 datacopy = obj.data.copy()
715 i = 0
716 for spline in datacopy.splines:
717 for pt in spline.bezier_points:
718 pt.co = key.data[i].co
719 pt.handle_left = key.data[i].handle_left
720 pt.handle_right = key.data[i].handle_right
721 i += 1
722 paths.append(Path(obj, datacopy, key.name))
723 return paths
725 def addShapeKeys(curve, paths, space):
726 for path in paths:
727 key = curve.shape_key_add(name = path.name)
728 pts = [pt for pset in path.getBezierPtsBySpline() for pt in pset]
729 for i, pt in enumerate(pts):
730 if(space == 'worldspace'):
731 pt = [curve.matrix_world.inverted() @ (path.curve.matrix_world @ p) for p in pt]
732 key.data[i].co = pt[0]
733 key.data[i].handle_left = pt[1]
734 key.data[i].handle_right = pt[2]
736 #TODO: Remove try
737 def safeRemoveCurveObj(obj):
738 try:
739 collections = obj.users_collection
741 for c in collections:
742 c.objects.unlink(obj)
744 if(obj.name in bpy.context.scene.collection.objects):
745 bpy.context.scene.collection.objects.unlink(obj)
747 if(obj.data.users == 1):
748 if(obj.type == 'CURVE'):
749 bpy.data.curves.remove(obj.data) #This also removes object?
750 elif(obj.type == 'MESH'):
751 bpy.data.meshes.remove(obj.data)
753 bpy.data.objects.remove(obj)
754 except:
755 pass
758 def markVertHandler(self, context):
759 if(self.markVertex):
760 bpy.ops.wm.mark_vertex()
763 #################### UI and Registration ####################
765 class AssignShapeKeysOp(bpy.types.Operator):
766 bl_idname = "object.assign_shape_keys"
767 bl_label = "Assign Shape Keys"
768 bl_options = {'REGISTER', 'UNDO'}
770 def execute(self, context):
771 params = context.window_manager.AssignShapeKeyParams
772 removeOriginal = params.removeOriginal
773 space = params.space
775 matchParts = params.matchParts
776 matchCri1 = params.matchCri1
777 matchCri2 = params.matchCri2
778 matchCri3 = params.matchCri3
780 alignBy = params.alignList
781 alignVal1 = params.alignVal1
782 alignVal2 = params.alignVal2
783 alignVal3 = params.alignVal3
785 createdObjsMap = main(removeOriginal, space, \
786 matchParts, [matchCri1, matchCri2, matchCri3], \
787 alignBy, [alignVal1, alignVal2, alignVal3])
789 return {'FINISHED'}
792 class MarkerController:
793 drawHandlerRef = None
794 defPointSize = 6
795 ptColor = (0, .8, .8, 1)
797 def createSMMap(self, context):
798 objs = context.selected_objects
799 smMap = {}
800 for curve in objs:
801 if(not isBezier(curve)):
802 continue
804 smMap[curve.name] = {}
805 mw = curve.matrix_world
806 for splineIdx, spline in enumerate(curve.data.splines):
807 if(not spline.use_cyclic_u):
808 continue
810 #initialize to the curr start vert co and idx
811 smMap[curve.name][splineIdx] = \
812 [mw @ curve.data.splines[splineIdx].bezier_points[0].co, 0]
814 for pt in spline.bezier_points:
815 pt.select_control_point = False
817 if(len(smMap[curve.name]) == 0):
818 del smMap[curve.name]
820 return smMap
822 def createBatch(self, context):
823 positions = [s[0] for cn in self.smMap.values() for s in cn.values()]
824 colors = [MarkerController.ptColor for i in range(0, len(positions))]
826 self.batch = batch_for_shader(self.shader, \
827 "POINTS", {"pos": positions, "color": colors})
829 if context.area:
830 context.area.tag_redraw()
832 def drawHandler(self):
833 bgl.glPointSize(MarkerController.defPointSize)
834 self.batch.draw(self.shader)
836 def removeMarkers(self, context):
837 if(MarkerController.drawHandlerRef != None):
838 bpy.types.SpaceView3D.draw_handler_remove(MarkerController.drawHandlerRef, \
839 "WINDOW")
841 if(context.area and hasattr(context.space_data, 'region_3d')):
842 context.area.tag_redraw()
844 MarkerController.drawHandlerRef = None
846 self.deselectAll()
848 def __init__(self, context):
849 self.smMap = self.createSMMap(context)
850 self.shader = gpu.shader.from_builtin('3D_FLAT_COLOR')
851 self.shader.bind()
853 MarkerController.drawHandlerRef = \
854 bpy.types.SpaceView3D.draw_handler_add(self.drawHandler, \
855 (), "WINDOW", "POST_VIEW")
857 self.createBatch(context)
859 def saveStartVerts(self):
860 for curveName in self.smMap.keys():
861 curve = bpy.data.objects[curveName]
862 splines = curve.data.splines
863 spMap = self.smMap[curveName]
865 for splineIdx in spMap.keys():
866 markerInfo = spMap[splineIdx]
867 if(markerInfo[1] != 0):
868 pts = splines[splineIdx].bezier_points
869 loc, idx = markerInfo[0], markerInfo[1]
870 cnt = len(pts)
872 ptCopy = [[p.co.copy(), p.handle_right.copy(), \
873 p.handle_left.copy(), p.handle_right_type, \
874 p.handle_left_type] for p in pts]
876 for i, pt in enumerate(pts):
877 srcIdx = (idx + i) % cnt
878 p = ptCopy[srcIdx]
880 #Must set the types first
881 pt.handle_right_type = p[3]
882 pt.handle_left_type = p[4]
883 pt.co = p[0]
884 pt.handle_right = p[1]
885 pt.handle_left = p[2]
887 def updateSMMap(self):
888 for curveName in self.smMap.keys():
889 curve = bpy.data.objects[curveName]
890 spMap = self.smMap[curveName]
891 mw = curve.matrix_world
893 for splineIdx in spMap.keys():
894 markerInfo = spMap[splineIdx]
895 loc, idx = markerInfo[0], markerInfo[1]
896 pts = curve.data.splines[splineIdx].bezier_points
898 selIdxs = [x for x in range(0, len(pts)) \
899 if pts[x].select_control_point == True]
901 selIdx = selIdxs[0] if(len(selIdxs) > 0 ) else idx
902 co = mw @ pts[selIdx].co
903 self.smMap[curveName][splineIdx] = [co, selIdx]
905 def deselectAll(self):
906 for curveName in self.smMap.keys():
907 curve = bpy.data.objects[curveName]
908 for spline in curve.data.splines:
909 for pt in spline.bezier_points:
910 pt.select_control_point = False
912 def getSpaces3D(context):
913 areas3d = [area for area in context.window.screen.areas \
914 if area.type == 'VIEW_3D']
916 return [s for a in areas3d for s in a.spaces if s.type == 'VIEW_3D']
918 def hideHandles(context):
919 states = []
920 spaces = MarkerController.getSpaces3D(context)
921 for s in spaces:
922 states.append(s.overlay.show_curve_handles)
923 s.overlay.show_curve_handles = False
924 return states
926 def resetShowHandleState(context, handleStates):
927 spaces = MarkerController.getSpaces3D(context)
928 for i, s in enumerate(spaces):
929 s.overlay.show_curve_handles = handleStates[i]
932 class ModalMarkSegStartOp(bpy.types.Operator):
933 bl_description = "Mark Vertex"
934 bl_idname = "wm.mark_vertex"
935 bl_label = "Mark Start Vertex"
937 def cleanup(self, context):
938 wm = context.window_manager
939 wm.event_timer_remove(self._timer)
940 self.markerState.removeMarkers(context)
941 MarkerController.resetShowHandleState(context, self.handleStates)
942 context.window_manager.AssignShapeKeyParams.markVertex = False
944 def modal (self, context, event):
945 params = context.window_manager.AssignShapeKeyParams
947 if(context.mode == 'OBJECT' or event.type == "ESC" or\
948 not context.window_manager.AssignShapeKeyParams.markVertex):
949 self.cleanup(context)
950 return {'CANCELLED'}
952 elif(event.type == "RET"):
953 self.markerState.saveStartVerts()
954 self.cleanup(context)
955 return {'FINISHED'}
957 if(event.type == 'TIMER'):
958 self.markerState.updateSMMap()
959 self.markerState.createBatch(context)
961 elif(event.type in {'LEFT_CTRL', 'RIGHT_CTRL'}):
962 self.ctrl = (event.value == 'PRESS')
964 elif(event.type in {'LEFT_SHIFT', 'RIGHT_SHIFT'}):
965 self.shift = (event.value == 'PRESS')
967 if(event.type not in {"MIDDLEMOUSE", "TAB", "LEFTMOUSE", \
968 "RIGHTMOUSE", 'WHEELDOWNMOUSE', 'WHEELUPMOUSE'} and \
969 not event.type.startswith("NUMPAD_")):
970 return {'RUNNING_MODAL'}
972 return {"PASS_THROUGH"}
974 def execute(self, context):
975 #TODO: Why such small step?
976 self._timer = context.window_manager.event_timer_add(time_step = 0.0001, \
977 window = context.window)
978 self.ctrl = False
979 self.shift = False
981 context.window_manager.modal_handler_add(self)
982 self.markerState = MarkerController(context)
984 #Hide so that users don't accidentally select handles instead of points
985 self.handleStates = MarkerController.hideHandles(context)
987 return {"RUNNING_MODAL"}
990 class AssignShapeKeyParams(bpy.types.PropertyGroup):
992 removeOriginal : BoolProperty(name = "Remove Shape Key Objects", \
993 description = "Remove shape key objects after assigning to target", \
994 default = True)
996 space : EnumProperty(name = "Space", \
997 items = [('worldspace', 'World Space', 'worldspace'),
998 ('localspace', 'Local Space', 'localspace')], \
999 description = 'Space that shape keys are evluated in')
1001 alignList : EnumProperty(name="Vertex Alignment", items = \
1002 [("-None-", 'Manual Alignment', "Align curve segments based on starting vertex"), \
1003 ('vertCo', 'Vertex Coordinates', 'Align curve segments based on vertex coordinates')], \
1004 description = 'Start aligning the vertices of target and shape keys from',
1005 default = '-None-')
1007 alignVal1 : EnumProperty(name="Value 1",
1008 items = matchList, default = 'minX', description='First align criterion')
1010 alignVal2 : EnumProperty(name="Value 2",
1011 items = matchList, default = 'maxY', description='Second align criterion')
1013 alignVal3 : EnumProperty(name="Value 3",
1014 items = matchList, default = 'minZ', description='Third align criterion')
1016 matchParts : EnumProperty(name="Match Parts", items = \
1017 [("-None-", 'None', "Don't match parts"), \
1018 ('default', 'Default', 'Use part (spline) order as in curve'), \
1019 ('custom', 'Custom', 'Use one of the custom criteria for part matching')], \
1020 description='Match disconnected parts', default = 'default')
1022 matchCri1 : EnumProperty(name="Value 1",
1023 items = matchList, default = 'minX', description='First match criterion')
1025 matchCri2 : EnumProperty(name="Value 2",
1026 items = matchList, default = 'maxY', description='Second match criterion')
1028 matchCri3 : EnumProperty(name="Value 3",
1029 items = matchList, default = 'minZ', description='Third match criterion')
1031 markVertex : BoolProperty(name="Mark Starting Vertices", \
1032 description='Mark first vertices in all splines of selected curves', \
1033 default = False, update = markVertHandler)
1036 class AssignShapeKeysPanel(bpy.types.Panel):
1038 bl_label = "Assign Shape Keys"
1039 bl_idname = "CURVE_PT_assign_shape_keys"
1040 bl_space_type = 'VIEW_3D'
1041 bl_region_type = 'UI'
1042 bl_category = "Tool"
1044 @classmethod
1045 def poll(cls, context):
1046 return context.mode in {'OBJECT', 'EDIT_CURVE'}
1048 def draw(self, context):
1050 layout = self.layout
1051 col = layout.column()
1052 params = context.window_manager.AssignShapeKeyParams
1054 if(context.mode == 'OBJECT'):
1055 row = col.row()
1056 row.prop(params, "removeOriginal")
1058 row = col.row()
1059 row.prop(params, "space")
1061 row = col.row()
1062 row.prop(params, "alignList")
1064 if(params.alignList == 'vertCo'):
1065 row = col.row()
1066 row.prop(params, "alignVal1")
1067 row.prop(params, "alignVal2")
1068 row.prop(params, "alignVal3")
1070 row = col.row()
1071 row.prop(params, "matchParts")
1073 if(params.matchParts == 'custom'):
1074 row = col.row()
1075 row.prop(params, "matchCri1")
1076 row.prop(params, "matchCri2")
1077 row.prop(params, "matchCri3")
1079 row = col.row()
1080 row.operator("object.assign_shape_keys")
1081 else:
1082 col.prop(params, "markVertex", \
1083 toggle = True)
1086 # registering and menu integration
1087 def register():
1088 bpy.utils.register_class(AssignShapeKeysPanel)
1089 bpy.utils.register_class(AssignShapeKeysOp)
1090 bpy.utils.register_class(AssignShapeKeyParams)
1091 bpy.types.WindowManager.AssignShapeKeyParams = \
1092 bpy.props.PointerProperty(type=AssignShapeKeyParams)
1093 bpy.utils.register_class(ModalMarkSegStartOp)
1095 def unregister():
1096 bpy.utils.unregister_class(AssignShapeKeysOp)
1097 bpy.utils.unregister_class(AssignShapeKeysPanel)
1098 del bpy.types.WindowManager.AssignShapeKeyParams
1099 bpy.utils.unregister_class(AssignShapeKeyParams)
1100 bpy.utils.unregister_class(ModalMarkSegStartOp)
1102 if __name__ == "__main__":
1103 register()