Pose Library: update for rename of asset_library to asset_library_ref
[blender-addons.git] / curve_assign_shapekey.py
blob643eb3a28ea7613f3e7e7558f878a56d1ecffb25
3 # This Blender add-on assigns one or more Bezier Curves as shape keys to another
4 # Bezier Curve
6 # Supported Blender Versions: 2.8x
8 # Copyright (C) 2019 Shrinivas Kulkarni
10 # License: GPL-3.0 (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, StringProperty
16 from collections import OrderedDict
17 from mathutils import Vector
18 from math import sqrt, floor
19 from functools import cmp_to_key
20 from bpy.types import Panel, Operator, AddonPreferences
23 bl_info = {
24 "name": "Assign Shape Keys",
25 "author": "Shrinivas Kulkarni",
26 "version": (1, 0, 1),
27 "blender": (2, 80, 0),
28 "location": "View 3D > Sidebar > Edit Tab",
29 "description": "Assigns one or more Bezier curves as shape keys to another Bezier curve",
30 "doc_url": "{BLENDER_MANUAL_URL}/addons/add_curve/assign_shape_keys.html",
31 "category": "Add Curve",
34 alignList = [('minX', 'Min X', 'Align vertices with Min X'),
35 ('maxX', 'Max X', 'Align vertices with Max X'),
36 ('minY', 'Min Y', 'Align vertices with Min Y'),
37 ('maxY', 'Max Y', 'Align vertices with Max Y'),
38 ('minZ', 'Min Z', 'Align vertices with Min Z'),
39 ('maxZ', 'Max Z', 'Align vertices with Max Z')]
41 matchList = [('vCnt', 'Vertex Count', 'Match by vertex count'),
42 ('bbArea', 'Area', 'Match by surface area of the bounding box'), \
43 ('bbHeight', 'Height', 'Match by bounding box height'), \
44 ('bbWidth', 'Width', 'Match by bounding box width'),
45 ('bbDepth', 'Depth', 'Match by bounding box depth'),
46 ('minX', 'Min X', 'Match by bounding box Min X'),
47 ('maxX', 'Max X', 'Match by bounding box Max X'),
48 ('minY', 'Min Y', 'Match by bounding box Min Y'),
49 ('maxY', 'Max Y', 'Match by bounding box Max Y'),
50 ('minZ', 'Min Z', 'Match by bounding box Min Z'),
51 ('maxZ', 'Max Z', 'Match by bounding box Max Z')]
53 DEF_ERR_MARGIN = 0.0001
55 def isBezier(obj):
56 return obj.type == 'CURVE' and len(obj.data.splines) > 0 \
57 and obj.data.splines[0].type == 'BEZIER'
59 #Avoid errors due to floating point conversions/comparisons
60 #TODO: return -1, 0, 1
61 def floatCmpWithMargin(float1, float2, margin = DEF_ERR_MARGIN):
62 return abs(float1 - float2) < margin
64 def vectCmpWithMargin(v1, v2, margin = DEF_ERR_MARGIN):
65 return all(floatCmpWithMargin(v1[i], v2[i], margin) for i in range(0, len(v1)))
67 class Segment():
69 #pts[0] - start, pts[1] - ctrl1, pts[2] - ctrl2, , pts[3] - end
70 def pointAtT(pts, t):
71 return pts[0] + t * (3 * (pts[1] - pts[0]) +
72 t* (3 * (pts[0] + pts[2]) - 6 * pts[1] +
73 t * (-pts[0] + 3 * (pts[1] - pts[2]) + pts[3])))
75 def getSegLenRecurs(pts, start, end, t1 = 0, t2 = 1, error = DEF_ERR_MARGIN):
76 t1_5 = (t1 + t2)/2
77 mid = Segment.pointAtT(pts, t1_5)
78 l = (end - start).length
79 l2 = (mid - start).length + (end - mid).length
80 if (l2 - l > error):
81 return (Segment.getSegLenRecurs(pts, start, mid, t1, t1_5, error) +
82 Segment.getSegLenRecurs(pts, mid, end, t1_5, t2, error))
83 return l2
85 def __init__(self, start, ctrl1, ctrl2, end):
86 self.start = start
87 self.ctrl1 = ctrl1
88 self.ctrl2 = ctrl2
89 self.end = end
90 pts = [start, ctrl1, ctrl2, end]
91 self.length = Segment.getSegLenRecurs(pts, start, end)
93 #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
94 def partialSeg(self, t0, t1):
95 pts = [self.start, self.ctrl1, self.ctrl2, self.end]
97 if(t0 > t1):
98 tt = t1
99 t1 = t0
100 t0 = tt
102 #Let's make at least the line segments of predictable length :)
103 if(pts[0] == pts[1] and pts[2] == pts[3]):
104 pt0 = Vector([(1 - t0) * pts[0][i] + t0 * pts[2][i] for i in range(0, 3)])
105 pt1 = Vector([(1 - t1) * pts[0][i] + t1 * pts[2][i] for i in range(0, 3)])
106 return Segment(pt0, pt0, pt1, pt1)
108 u0 = 1.0 - t0
109 u1 = 1.0 - t1
111 qa = [pts[0][i]*u0*u0 + pts[1][i]*2*t0*u0 + pts[2][i]*t0*t0 for i in range(0, 3)]
112 qb = [pts[0][i]*u1*u1 + pts[1][i]*2*t1*u1 + pts[2][i]*t1*t1 for i in range(0, 3)]
113 qc = [pts[1][i]*u0*u0 + pts[2][i]*2*t0*u0 + pts[3][i]*t0*t0 for i in range(0, 3)]
114 qd = [pts[1][i]*u1*u1 + pts[2][i]*2*t1*u1 + pts[3][i]*t1*t1 for i in range(0, 3)]
116 pta = Vector([qa[i]*u0 + qc[i]*t0 for i in range(0, 3)])
117 ptb = Vector([qa[i]*u1 + qc[i]*t1 for i in range(0, 3)])
118 ptc = Vector([qb[i]*u0 + qd[i]*t0 for i in range(0, 3)])
119 ptd = Vector([qb[i]*u1 + qd[i]*t1 for i in range(0, 3)])
121 return Segment(pta, ptb, ptc, ptd)
123 #see https://stackoverflow.com/questions/24809978/calculating-the-bounding-box-of-cubic-bezier-curve
124 #(3 D - 9 C + 9 B - 3 A) t^2 + (6 A - 12 B + 6 C) t + 3 (B - A)
125 #pts[0] - start, pts[1] - ctrl1, pts[2] - ctrl2, , pts[3] - end
126 #TODO: Return Vectors to make world space calculations consistent
127 def bbox(self, mw = None):
128 def evalBez(AA, BB, CC, DD, t):
129 return AA * (1 - t) * (1 - t) * (1 - t) + \
130 3 * BB * t * (1 - t) * (1 - t) + \
131 3 * CC * t * t * (1 - t) + \
132 DD * t * t * t
134 A = self.start
135 B = self.ctrl1
136 C = self.ctrl2
137 D = self.end
139 if(mw != None):
140 A = mw @ A
141 B = mw @ B
142 C = mw @ C
143 D = mw @ D
145 MINXYZ = [min([A[i], D[i]]) for i in range(0, 3)]
146 MAXXYZ = [max([A[i], D[i]]) for i in range(0, 3)]
147 leftBotBack_rgtTopFront = [MINXYZ, MAXXYZ]
149 a = [3 * D[i] - 9 * C[i] + 9 * B[i] - 3 * A[i] for i in range(0, 3)]
150 b = [6 * A[i] - 12 * B[i] + 6 * C[i] for i in range(0, 3)]
151 c = [3 * (B[i] - A[i]) for i in range(0, 3)]
153 solnsxyz = []
154 for i in range(0, 3):
155 solns = []
156 if(a[i] == 0):
157 if(b[i] == 0):
158 solns.append(0)#Independent of t so lets take the starting pt
159 else:
160 solns.append(c[i] / b[i])
161 else:
162 rootFact = b[i] * b[i] - 4 * a[i] * c[i]
163 if(rootFact >=0 ):
164 #Two solutions with + and - sqrt
165 solns.append((-b[i] + sqrt(rootFact)) / (2 * a[i]))
166 solns.append((-b[i] - sqrt(rootFact)) / (2 * a[i]))
167 solnsxyz.append(solns)
169 for i, soln in enumerate(solnsxyz):
170 for j, t in enumerate(soln):
171 if(t < 1 and t > 0):
172 co = evalBez(A[i], B[i], C[i], D[i], t)
173 if(co < leftBotBack_rgtTopFront[0][i]):
174 leftBotBack_rgtTopFront[0][i] = co
175 if(co > leftBotBack_rgtTopFront[1][i]):
176 leftBotBack_rgtTopFront[1][i] = co
178 return leftBotBack_rgtTopFront
181 class Part():
182 def __init__(self, parent, segs, isClosed):
183 self.parent = parent
184 self.segs = segs
186 #use_cyclic_u
187 self.isClosed = isClosed
189 #Indicates if this should be closed based on its counterparts in other paths
190 self.toClose = isClosed
192 self.length = sum(seg.length for seg in self.segs)
193 self.bbox = None
194 self.bboxWorldSpace = None
196 def getSeg(self, idx):
197 return self.segs[idx]
199 def getSegs(self):
200 return self.segs
202 def getSegsCopy(self, start, end):
203 if(start == None):
204 start = 0
205 if(end == None):
206 end = len(self.segs)
207 return self.segs[start:end]
209 def getBBox(self, worldSpace):
210 #Avoid frequent calculations, as this will be called in compare method
211 if(not worldSpace and self.bbox != None):
212 return self.bbox
214 if(worldSpace and self.bboxWorldSpace != None):
215 return self.bboxWorldSpace
217 leftBotBack_rgtTopFront = [[None]*3,[None]*3]
219 for seg in self.segs:
221 if(worldSpace):
222 bb = seg.bbox(self.parent.curve.matrix_world)
223 else:
224 bb = seg.bbox()
226 for i in range(0, 3):
227 if (leftBotBack_rgtTopFront[0][i] == None or \
228 bb[0][i] < leftBotBack_rgtTopFront[0][i]):
229 leftBotBack_rgtTopFront[0][i] = bb[0][i]
231 for i in range(0, 3):
232 if (leftBotBack_rgtTopFront[1][i] == None or \
233 bb[1][i] > leftBotBack_rgtTopFront[1][i]):
234 leftBotBack_rgtTopFront[1][i] = bb[1][i]
236 if(worldSpace):
237 self.bboxWorldSpace = leftBotBack_rgtTopFront
238 else:
239 self.bbox = leftBotBack_rgtTopFront
241 return leftBotBack_rgtTopFront
243 #private
244 def getBBDiff(self, axisIdx, worldSpace):
245 obj = self.parent.curve
246 bbox = self.getBBox(worldSpace)
247 diff = abs(bbox[1][axisIdx] - bbox[0][axisIdx])
248 return diff
250 def getBBWidth(self, worldSpace):
251 return self.getBBDiff(0, worldSpace)
253 def getBBHeight(self, worldSpace):
254 return self.getBBDiff(1, worldSpace)
256 def getBBDepth(self, worldSpace):
257 return self.getBBDiff(2, worldSpace)
259 def bboxSurfaceArea(self, worldSpace):
260 leftBotBack_rgtTopFront = self.getBBox(worldSpace)
261 w = abs( leftBotBack_rgtTopFront[1][0] - leftBotBack_rgtTopFront[0][0] )
262 l = abs( leftBotBack_rgtTopFront[1][1] - leftBotBack_rgtTopFront[0][1] )
263 d = abs( leftBotBack_rgtTopFront[1][2] - leftBotBack_rgtTopFront[0][2] )
265 return 2 * (w * l + w * d + l * d)
267 def getSegCnt(self):
268 return len(self.segs)
270 def getBezierPtsInfo(self):
271 prevSeg = None
272 bezierPtsInfo = []
274 for j, seg in enumerate(self.getSegs()):
276 pt = seg.start
277 handleRight = seg.ctrl1
279 if(j == 0):
280 if(self.toClose):
281 handleLeft = self.getSeg(-1).ctrl2
282 else:
283 handleLeft = pt
284 else:
285 handleLeft = prevSeg.ctrl2
287 bezierPtsInfo.append([pt, handleLeft, handleRight])
288 prevSeg = seg
290 if(self.toClose == True):
291 bezierPtsInfo[-1][2] = seg.ctrl1
292 else:
293 bezierPtsInfo.append([prevSeg.end, prevSeg.ctrl2, prevSeg.end])
295 return bezierPtsInfo
297 def __repr__(self):
298 return str(self.length)
301 class Path:
302 def __init__(self, curve, objData = None, name = None):
304 if(objData == None):
305 objData = curve.data
307 if(name == None):
308 name = curve.name
310 self.name = name
311 self.curve = curve
313 self.parts = [Part(self, getSplineSegs(s), s.use_cyclic_u) for s in objData.splines]
315 def getPartCnt(self):
316 return len(self.parts)
318 def getPartView(self):
319 p = Part(self, [seg for part in self.parts for seg in part.getSegs()], None)
320 return p
322 def getPartBoundaryIdxs(self):
323 cumulCntList = set()
324 cumulCnt = 0
326 for p in self.parts:
327 cumulCnt += p.getSegCnt()
328 cumulCntList.add(cumulCnt)
330 return cumulCntList
332 def updatePartsList(self, segCntsPerPart, byPart):
333 monolithicSegList = [seg for part in self.parts for seg in part.getSegs()]
334 oldParts = self.parts[:]
335 currPart = oldParts[0]
336 partIdx = 0
337 self.parts.clear()
339 for i in range(0, len(segCntsPerPart)):
340 if( i == 0):
341 currIdx = 0
342 else:
343 currIdx = segCntsPerPart[i-1]
345 nextIdx = segCntsPerPart[i]
346 isClosed = False
348 if(vectCmpWithMargin(monolithicSegList[currIdx].start, \
349 currPart.getSegs()[0].start) and \
350 vectCmpWithMargin(monolithicSegList[nextIdx-1].end, \
351 currPart.getSegs()[-1].end)):
352 isClosed = currPart.isClosed
354 self.parts.append(Part(self, \
355 monolithicSegList[currIdx:nextIdx], isClosed))
357 if(monolithicSegList[nextIdx-1] == currPart.getSegs()[-1]):
358 partIdx += 1
359 if(partIdx < len(oldParts)):
360 currPart = oldParts[partIdx]
362 def getBezierPtsBySpline(self):
363 data = []
365 for i, part in enumerate(self.parts):
366 data.append(part.getBezierPtsInfo())
368 return data
370 def getNewCurveData(self):
372 newCurveData = self.curve.data.copy()
373 newCurveData.splines.clear()
375 splinesData = self.getBezierPtsBySpline()
377 for i, newPoints in enumerate(splinesData):
379 spline = newCurveData.splines.new('BEZIER')
380 spline.bezier_points.add(len(newPoints)-1)
381 spline.use_cyclic_u = self.parts[i].toClose
383 for j in range(0, len(spline.bezier_points)):
384 newPoint = newPoints[j]
385 spline.bezier_points[j].co = newPoint[0]
386 spline.bezier_points[j].handle_left = newPoint[1]
387 spline.bezier_points[j].handle_right = newPoint[2]
388 spline.bezier_points[j].handle_right_type = 'FREE'
390 return newCurveData
392 def updateCurve(self):
393 curveData = self.curve.data
394 #Remove existing shape keys first
395 if(curveData.shape_keys != None):
396 keyblocks = reversed(curveData.shape_keys.key_blocks)
397 for sk in keyblocks:
398 self.curve.shape_key_remove(sk)
399 self.curve.data = self.getNewCurveData()
400 bpy.data.curves.remove(curveData)
402 def main(targetObj, shapekeyObjs, removeOriginal, space, matchParts, \
403 matchCriteria, alignBy, alignValues):
405 target = Path(targetObj)
407 shapekeys = [Path(c) for c in shapekeyObjs]
409 existingKeys = getExistingShapeKeyPaths(target)
410 shapekeys = existingKeys + shapekeys
411 userSel = [target] + shapekeys
413 for path in userSel:
414 alignPath(path, matchParts, matchCriteria, alignBy, alignValues)
416 addMissingSegs(userSel, byPart = (matchParts != "-None-"))
418 bIdxs = set()
419 for path in userSel:
420 bIdxs = bIdxs.union(path.getPartBoundaryIdxs())
422 for path in userSel:
423 path.updatePartsList(sorted(list(bIdxs)), byPart = False)
425 #All will have the same part count by now
426 allToClose = [all(path.parts[j].isClosed for path in userSel)
427 for j in range(0, len(userSel[0].parts))]
429 #All paths will have the same no of splines with the same no of bezier points
430 for path in userSel:
431 for j, part in enumerate(path.parts):
432 part.toClose = allToClose[j]
434 target.updateCurve()
436 if(len(existingKeys) == 0):
437 target.curve.shape_key_add(name = 'Basis')
439 addShapeKeys(target.curve, shapekeys, space)
441 if(removeOriginal):
442 for path in userSel:
443 if(path.curve != target.curve):
444 safeRemoveObj(path.curve)
446 def getSplineSegs(spline):
447 p = spline.bezier_points
448 segs = [Segment(p[i-1].co, p[i-1].handle_right, p[i].handle_left, p[i].co) \
449 for i in range(1, len(p))]
450 if(spline.use_cyclic_u):
451 segs.append(Segment(p[-1].co, p[-1].handle_right, p[0].handle_left, p[0].co))
452 return segs
454 def subdivideSeg(origSeg, noSegs):
455 if(noSegs < 2):
456 return [origSeg]
458 segs = []
459 oldT = 0
460 segLen = origSeg.length / noSegs
462 for i in range(0, noSegs-1):
463 t = float(i+1) / noSegs
464 seg = origSeg.partialSeg(oldT, t)
465 segs.append(seg)
466 oldT = t
468 seg = origSeg.partialSeg(oldT, 1)
469 segs.append(seg)
471 return segs
474 def getSubdivCntPerSeg(part, toAddCnt):
476 class SegWrapper:
477 def __init__(self, idx, seg):
478 self.idx = idx
479 self.seg = seg
480 self.length = seg.length
482 class PartWrapper:
483 def __init__(self, part):
484 self.segList = []
485 self.segCnt = len(part.getSegs())
486 for idx, seg in enumerate(part.getSegs()):
487 self.segList.append(SegWrapper(idx, seg))
489 partWrapper = PartWrapper(part)
490 partLen = part.length
491 avgLen = partLen / (partWrapper.segCnt + toAddCnt)
493 segsToDivide = [sr for sr in partWrapper.segList if sr.seg.length >= avgLen]
494 segToDivideCnt = len(segsToDivide)
495 avgLen = sum(sr.seg.length for sr in segsToDivide) / (segToDivideCnt + toAddCnt)
497 segsToDivide = sorted(segsToDivide, key=lambda x: x.length, reverse = True)
499 cnts = [0] * partWrapper.segCnt
500 addedCnt = 0
503 for i in range(0, segToDivideCnt):
504 segLen = segsToDivide[i].seg.length
506 divideCnt = int(round(segLen/avgLen)) - 1
507 if(divideCnt == 0):
508 break
510 if((addedCnt + divideCnt) >= toAddCnt):
511 cnts[segsToDivide[i].idx] = toAddCnt - addedCnt
512 addedCnt = toAddCnt
513 break
515 cnts[segsToDivide[i].idx] = divideCnt
517 addedCnt += divideCnt
519 #TODO: Verify if needed
520 while(toAddCnt > addedCnt):
521 for i in range(0, segToDivideCnt):
522 cnts[segsToDivide[i].idx] += 1
523 addedCnt += 1
524 if(toAddCnt == addedCnt):
525 break
527 return cnts
529 #Just distribute equally; this is likely a rare condition. So why complicate?
530 def distributeCnt(maxSegCntsByPart, startIdx, extraCnt):
531 added = 0
532 elemCnt = len(maxSegCntsByPart) - startIdx
533 cntPerElem = floor(extraCnt / elemCnt)
534 remainder = extraCnt % elemCnt
536 for i in range(startIdx, len(maxSegCntsByPart)):
537 maxSegCntsByPart[i] += cntPerElem
538 if(i < remainder + startIdx):
539 maxSegCntsByPart[i] += 1
541 #Make all the paths to have the maximum number of segments in the set
542 #TODO: Refactor
543 def addMissingSegs(selPaths, byPart):
544 maxSegCntsByPart = []
545 maxSegCnt = 0
547 resSegCnt = []
548 sortedPaths = sorted(selPaths, key = lambda c: -len(c.parts))
550 for i, path in enumerate(sortedPaths):
551 if(byPart == False):
552 segCnt = path.getPartView().getSegCnt()
553 if(segCnt > maxSegCnt):
554 maxSegCnt = segCnt
555 else:
556 resSegCnt.append([])
557 for j, part in enumerate(path.parts):
558 partSegCnt = part.getSegCnt()
559 resSegCnt[i].append(partSegCnt)
561 #First path
562 if(j == len(maxSegCntsByPart)):
563 maxSegCntsByPart.append(partSegCnt)
565 #last part of this path, but other paths in set have more parts
566 elif((j == len(path.parts) - 1) and
567 len(maxSegCntsByPart) > len(path.parts)):
569 remainingSegs = sum(maxSegCntsByPart[j:])
570 if(partSegCnt <= remainingSegs):
571 resSegCnt[i][j] = remainingSegs
572 else:
573 #This part has more segs than the sum of the remaining part segs
574 #So distribute the extra count
575 distributeCnt(maxSegCntsByPart, j, (partSegCnt - remainingSegs))
577 #Also, adjust the seg count of the last part of the previous
578 #segments that had fewer than max number of parts
579 for k in range(0, i):
580 if(len(sortedPaths[k].parts) < len(maxSegCntsByPart)):
581 totalSegs = sum(maxSegCntsByPart)
582 existingSegs = sum(maxSegCntsByPart[:len(sortedPaths[k].parts)-1])
583 resSegCnt[k][-1] = totalSegs - existingSegs
585 elif(partSegCnt > maxSegCntsByPart[j]):
586 maxSegCntsByPart[j] = partSegCnt
587 for i, path in enumerate(sortedPaths):
589 if(byPart == False):
590 partView = path.getPartView()
591 segCnt = partView.getSegCnt()
592 diff = maxSegCnt - segCnt
594 if(diff > 0):
595 cnts = getSubdivCntPerSeg(partView, diff)
596 cumulSegIdx = 0
597 for j in range(0, len(path.parts)):
598 part = path.parts[j]
599 newSegs = []
600 for k, seg in enumerate(part.getSegs()):
601 numSubdivs = cnts[cumulSegIdx] + 1
602 newSegs += subdivideSeg(seg, numSubdivs)
603 cumulSegIdx += 1
605 path.parts[j] = Part(path, newSegs, part.isClosed)
606 else:
607 for j in range(0, len(path.parts)):
608 part = path.parts[j]
609 newSegs = []
611 partSegCnt = part.getSegCnt()
613 #TODO: Adding everything in the last part?
614 if(j == (len(path.parts)-1) and
615 len(maxSegCntsByPart) > len(path.parts)):
616 diff = resSegCnt[i][j] - partSegCnt
617 else:
618 diff = maxSegCntsByPart[j] - partSegCnt
620 if(diff > 0):
621 cnts = getSubdivCntPerSeg(part, diff)
623 for k, seg in enumerate(part.getSegs()):
624 seg = part.getSeg(k)
625 subdivCnt = cnts[k] + 1 #1 for the existing one
626 newSegs += subdivideSeg(seg, subdivCnt)
628 #isClosed won't be used, but let's update anyway
629 path.parts[j] = Part(path, newSegs, part.isClosed)
631 #TODO: Simplify (Not very readable)
632 def alignPath(path, matchParts, matchCriteria, alignBy, alignValues):
634 parts = path.parts[:]
636 if(matchParts == 'custom'):
637 fnMap = {'vCnt' : lambda part: -1 * part.getSegCnt(), \
638 'bbArea': lambda part: -1 * part.bboxSurfaceArea(worldSpace = True), \
639 'bbHeight' : lambda part: -1 * part.getBBHeight(worldSpace = True), \
640 'bbWidth' : lambda part: -1 * part.getBBWidth(worldSpace = True), \
641 'bbDepth' : lambda part: -1 * part.getBBDepth(worldSpace = True)
643 matchPartCmpFns = []
644 for criterion in matchCriteria:
645 fn = fnMap.get(criterion)
646 if(fn == None):
647 minmax = criterion[:3] == 'max' #0 if min; 1 if max
648 axisIdx = ord(criterion[3:]) - ord('X')
650 fn = eval('lambda part: part.getBBox(worldSpace = True)[' + \
651 str(minmax) + '][' + str(axisIdx) + ']')
653 matchPartCmpFns.append(fn)
655 def comparer(left, right):
656 for fn in matchPartCmpFns:
657 a = fn(left)
658 b = fn(right)
660 if(floatCmpWithMargin(a, b)):
661 continue
662 else:
663 return (a > b) - ( a < b) #No cmp in python3
665 return 0
667 parts = sorted(parts, key = cmp_to_key(comparer))
669 alignCmpFn = None
670 if(alignBy == 'vertCo'):
671 def evalCmp(criteria, pt1, pt2):
672 if(len(criteria) == 0):
673 return True
675 minmax = criteria[0][0]
676 axisIdx = criteria[0][1]
677 val1 = pt1[axisIdx]
678 val2 = pt2[axisIdx]
680 if(floatCmpWithMargin(val1, val2)):
681 criteria = criteria[:]
682 criteria.pop(0)
683 return evalCmp(criteria, pt1, pt2)
685 return val1 < val2 if minmax == 'min' else val1 > val2
687 alignCri = [[a[:3], ord(a[3:]) - ord('X')] for a in alignValues]
688 alignCmpFn = lambda pt1, pt2, curve: (evalCmp(alignCri, \
689 curve.matrix_world @ pt1, curve.matrix_world @ pt2))
691 startPt = None
692 startIdx = None
694 for i in range(0, len(parts)):
695 #Only truly closed parts
696 if(alignCmpFn != None and parts[i].isClosed):
697 for j in range(0, parts[i].getSegCnt()):
698 seg = parts[i].getSeg(j)
699 if(j == 0 or alignCmpFn(seg.start, startPt, path.curve)):
700 startPt = seg.start
701 startIdx = j
703 path.parts[i]= Part(path, parts[i].getSegsCopy(startIdx, None) + \
704 parts[i].getSegsCopy(None, startIdx), parts[i].isClosed)
705 else:
706 path.parts[i] = parts[i]
708 #TODO: Other shape key attributes like interpolation...?
709 def getExistingShapeKeyPaths(path):
710 obj = path.curve
711 paths = []
713 if(obj.data.shape_keys != None):
714 keyblocks = obj.data.shape_keys.key_blocks[:]
715 for key in keyblocks:
716 datacopy = obj.data.copy()
717 i = 0
718 for spline in datacopy.splines:
719 for pt in spline.bezier_points:
720 pt.co = key.data[i].co
721 pt.handle_left = key.data[i].handle_left
722 pt.handle_right = key.data[i].handle_right
723 i += 1
724 paths.append(Path(obj, datacopy, key.name))
725 return paths
727 def addShapeKeys(curve, paths, space):
728 for path in paths:
729 key = curve.shape_key_add(name = path.name)
730 pts = [pt for pset in path.getBezierPtsBySpline() for pt in pset]
731 for i, pt in enumerate(pts):
732 if(space == 'worldspace'):
733 pt = [curve.matrix_world.inverted() @ (path.curve.matrix_world @ p) for p in pt]
734 key.data[i].co = pt[0]
735 key.data[i].handle_left = pt[1]
736 key.data[i].handle_right = pt[2]
738 #TODO: Remove try
739 def safeRemoveObj(obj):
740 try:
741 collections = obj.users_collection
743 for c in collections:
744 c.objects.unlink(obj)
746 if(obj.name in bpy.context.scene.collection.objects):
747 bpy.context.scene.collection.objects.unlink(obj)
749 if(obj.data.users == 1):
750 if(obj.type == 'CURVE'):
751 bpy.data.curves.remove(obj.data) #This also removes object?
752 elif(obj.type == 'MESH'):
753 bpy.data.meshes.remove(obj.data)
755 bpy.data.objects.remove(obj)
756 except:
757 pass
760 def markVertHandler(self, context):
761 if(self.markVertex):
762 bpy.ops.wm.mark_vertex()
765 #################### UI and Registration ####################
767 class AssignShapeKeysOp(Operator):
768 bl_idname = "object.assign_shape_keys"
769 bl_label = "Assign Shape Keys"
770 bl_options = {'REGISTER', 'UNDO'}
772 def execute(self, context):
773 params = context.window_manager.AssignShapeKeyParams
774 removeOriginal = params.removeOriginal
775 space = params.space
777 matchParts = params.matchParts
778 matchCri1 = params.matchCri1
779 matchCri2 = params.matchCri2
780 matchCri3 = params.matchCri3
782 alignBy = params.alignCos
783 alignVal1 = params.alignVal1
784 alignVal2 = params.alignVal2
785 alignVal3 = params.alignVal3
787 targetObj = bpy.context.active_object
788 shapekeyObjs = [obj for obj in bpy.context.selected_objects if isBezier(obj) \
789 and obj != targetObj]
791 if(targetObj != None and isBezier(targetObj) and len(shapekeyObjs) > 0):
792 main(targetObj, shapekeyObjs, removeOriginal, space, \
793 matchParts, [matchCri1, matchCri2, matchCri3], \
794 alignBy, [alignVal1, alignVal2, alignVal3])
796 return {'FINISHED'}
799 class MarkerController:
800 drawHandlerRef = None
801 defPointSize = 6
802 ptColor = (0, .8, .8, 1)
804 def createSMMap(self, context):
805 objs = context.selected_objects
806 smMap = {}
807 for curve in objs:
808 if(not isBezier(curve)):
809 continue
811 smMap[curve.name] = {}
812 mw = curve.matrix_world
813 for splineIdx, spline in enumerate(curve.data.splines):
814 if(not spline.use_cyclic_u):
815 continue
817 #initialize to the curr start vert co and idx
818 smMap[curve.name][splineIdx] = \
819 [mw @ curve.data.splines[splineIdx].bezier_points[0].co, 0]
821 for pt in spline.bezier_points:
822 pt.select_control_point = False
824 if(len(smMap[curve.name]) == 0):
825 del smMap[curve.name]
827 return smMap
829 def createBatch(self, context):
830 positions = [s[0] for cn in self.smMap.values() for s in cn.values()]
831 colors = [MarkerController.ptColor for i in range(0, len(positions))]
833 self.batch = batch_for_shader(self.shader, \
834 "POINTS", {"pos": positions, "color": colors})
836 if context.area:
837 context.area.tag_redraw()
839 def drawHandler(self):
840 bgl.glPointSize(MarkerController.defPointSize)
841 self.batch.draw(self.shader)
843 def removeMarkers(self, context):
844 if(MarkerController.drawHandlerRef != None):
845 bpy.types.SpaceView3D.draw_handler_remove(MarkerController.drawHandlerRef, \
846 "WINDOW")
848 if(context.area and hasattr(context.space_data, 'region_3d')):
849 context.area.tag_redraw()
851 MarkerController.drawHandlerRef = None
853 self.deselectAll()
855 def __init__(self, context):
856 self.smMap = self.createSMMap(context)
857 self.shader = gpu.shader.from_builtin('3D_FLAT_COLOR')
858 # self.shader.bind()
860 MarkerController.drawHandlerRef = \
861 bpy.types.SpaceView3D.draw_handler_add(self.drawHandler, \
862 (), "WINDOW", "POST_VIEW")
864 self.createBatch(context)
866 def saveStartVerts(self):
867 for curveName in self.smMap.keys():
868 curve = bpy.data.objects[curveName]
869 splines = curve.data.splines
870 spMap = self.smMap[curveName]
872 for splineIdx in spMap.keys():
873 markerInfo = spMap[splineIdx]
874 if(markerInfo[1] != 0):
875 pts = splines[splineIdx].bezier_points
876 loc, idx = markerInfo[0], markerInfo[1]
877 cnt = len(pts)
879 ptCopy = [[p.co.copy(), p.handle_right.copy(), \
880 p.handle_left.copy(), p.handle_right_type, \
881 p.handle_left_type] for p in pts]
883 for i, pt in enumerate(pts):
884 srcIdx = (idx + i) % cnt
885 p = ptCopy[srcIdx]
887 #Must set the types first
888 pt.handle_right_type = p[3]
889 pt.handle_left_type = p[4]
890 pt.co = p[0]
891 pt.handle_right = p[1]
892 pt.handle_left = p[2]
894 def updateSMMap(self):
895 for curveName in self.smMap.keys():
896 curve = bpy.data.objects[curveName]
897 spMap = self.smMap[curveName]
898 mw = curve.matrix_world
900 for splineIdx in spMap.keys():
901 markerInfo = spMap[splineIdx]
902 loc, idx = markerInfo[0], markerInfo[1]
903 pts = curve.data.splines[splineIdx].bezier_points
905 selIdxs = [x for x in range(0, len(pts)) \
906 if pts[x].select_control_point == True]
908 selIdx = selIdxs[0] if(len(selIdxs) > 0 ) else idx
909 co = mw @ pts[selIdx].co
910 self.smMap[curveName][splineIdx] = [co, selIdx]
912 def deselectAll(self):
913 for curveName in self.smMap.keys():
914 curve = bpy.data.objects[curveName]
915 for spline in curve.data.splines:
916 for pt in spline.bezier_points:
917 pt.select_control_point = False
919 def getSpaces3D(context):
920 areas3d = [area for area in context.window.screen.areas \
921 if area.type == 'VIEW_3D']
923 return [s for a in areas3d for s in a.spaces if s.type == 'VIEW_3D']
925 def hideHandles(context):
926 states = []
927 spaces = MarkerController.getSpaces3D(context)
928 for s in spaces:
929 if(hasattr(s.overlay, 'show_curve_handles')):
930 states.append(s.overlay.show_curve_handles)
931 s.overlay.show_curve_handles = False
932 elif(hasattr(s.overlay, 'display_handle')): # 2.90
933 states.append(s.overlay.display_handle)
934 s.overlay.display_handle = 'NONE'
935 return states
937 def resetShowHandleState(context, handleStates):
938 spaces = MarkerController.getSpaces3D(context)
939 for i, s in enumerate(spaces):
940 if(hasattr(s.overlay, 'show_curve_handles')):
941 s.overlay.show_curve_handles = handleStates[i]
942 elif(hasattr(s.overlay, 'display_handle')): # 2.90
943 s.overlay.display_handle = handleStates[i]
946 class ModalMarkSegStartOp(Operator):
947 bl_description = "Mark Vertex"
948 bl_idname = "wm.mark_vertex"
949 bl_label = "Mark Start Vertex"
951 def cleanup(self, context):
952 wm = context.window_manager
953 wm.event_timer_remove(self._timer)
954 self.markerState.removeMarkers(context)
955 MarkerController.resetShowHandleState(context, self.handleStates)
956 context.window_manager.AssignShapeKeyParams.markVertex = False
958 def modal (self, context, event):
959 params = context.window_manager.AssignShapeKeyParams
961 if(context.mode == 'OBJECT' or event.type == "ESC" or\
962 not context.window_manager.AssignShapeKeyParams.markVertex):
963 self.cleanup(context)
964 return {'CANCELLED'}
966 elif(event.type == "RET"):
967 self.markerState.saveStartVerts()
968 self.cleanup(context)
969 return {'FINISHED'}
971 if(event.type == 'TIMER'):
972 self.markerState.updateSMMap()
973 self.markerState.createBatch(context)
975 return {"PASS_THROUGH"}
977 def execute(self, context):
978 #TODO: Why such small step?
979 self._timer = context.window_manager.event_timer_add(time_step = 0.0001, \
980 window = context.window)
982 context.window_manager.modal_handler_add(self)
983 self.markerState = MarkerController(context)
985 #Hide so that users don't accidentally select handles instead of points
986 self.handleStates = MarkerController.hideHandles(context)
988 return {"RUNNING_MODAL"}
991 class AssignShapeKeyParams(bpy.types.PropertyGroup):
993 removeOriginal : BoolProperty(name = "Remove Shape Key Objects", \
994 description = "Remove shape key objects after assigning to target", \
995 default = True)
997 space : EnumProperty(name = "Space", \
998 items = [('worldspace', 'World Space', 'worldspace'),
999 ('localspace', 'Local Space', 'localspace')], \
1000 description = 'Space that shape keys are evluated in')
1002 alignCos : EnumProperty(name="Vertex Alignment", items = \
1003 [("-None-", 'Manual Alignment', "Align curve segments based on starting vertex"), \
1004 ('vertCo', 'Vertex Coordinates', 'Align curve segments based on vertex coordinates')], \
1005 description = 'Start aligning the vertices of target and shape keys from',
1006 default = '-None-')
1008 alignVal1 : EnumProperty(name="Value 1",
1009 items = alignList, default = 'minX', description='First align criterion')
1011 alignVal2 : EnumProperty(name="Value 2",
1012 items = alignList, default = 'maxY', description='Second align criterion')
1014 alignVal3 : EnumProperty(name="Value 3",
1015 items = alignList, default = 'minZ', description='Third align criterion')
1017 matchParts : EnumProperty(name="Match Parts", items = \
1018 [("-None-", 'None', "Don't match parts"), \
1019 ('default', 'Default', 'Use part (spline) order as in curve'), \
1020 ('custom', 'Custom', 'Use one of the custom criteria for part matching')], \
1021 description='Match disconnected parts', default = 'default')
1023 matchCri1 : EnumProperty(name="Value 1",
1024 items = matchList, default = 'minX', description='First match criterion')
1026 matchCri2 : EnumProperty(name="Value 2",
1027 items = matchList, default = 'maxY', description='Second match criterion')
1029 matchCri3 : EnumProperty(name="Value 3",
1030 items = matchList, default = 'minZ', description='Third match criterion')
1032 markVertex : BoolProperty(name="Mark Starting Vertices", \
1033 description='Mark first vertices in all splines of selected curves', \
1034 default = False, update = markVertHandler)
1037 class AssignShapeKeysPanel(Panel):
1039 bl_label = "Curve Shape Keys"
1040 bl_idname = "CURVE_PT_assign_shape_keys"
1041 bl_space_type = 'VIEW_3D'
1042 bl_region_type = 'UI'
1043 bl_category = "Edit"
1044 bl_options = {'DEFAULT_CLOSED'}
1046 @classmethod
1047 def poll(cls, context):
1048 return context.mode in {'OBJECT', 'EDIT_CURVE'}
1050 def draw(self, context):
1052 layout = self.layout
1053 layout.label(text='Morph Curves:')
1054 col = layout.column()
1055 params = context.window_manager.AssignShapeKeyParams
1057 if(context.mode == 'OBJECT'):
1058 row = col.row()
1059 row.prop(params, "removeOriginal")
1061 row = col.row()
1062 row.prop(params, "space")
1064 row = col.row()
1065 row.prop(params, "alignCos")
1067 if(params.alignCos == 'vertCo'):
1068 row = col.row()
1069 row.prop(params, "alignVal1")
1070 row.prop(params, "alignVal2")
1071 row.prop(params, "alignVal3")
1073 row = col.row()
1074 row.prop(params, "matchParts")
1076 if(params.matchParts == 'custom'):
1077 row = col.row()
1078 row.prop(params, "matchCri1")
1079 row.prop(params, "matchCri2")
1080 row.prop(params, "matchCri3")
1082 row = col.row()
1083 row.operator("object.assign_shape_keys")
1084 else:
1085 col.prop(params, "markVertex", \
1086 toggle = True)
1089 def updatePanel(self, context):
1090 try:
1091 panel = AssignShapeKeysPanel
1092 if "bl_rna" in panel.__dict__:
1093 bpy.utils.unregister_class(panel)
1095 panel.bl_category = context.preferences.addons[__name__].preferences.category
1096 bpy.utils.register_class(panel)
1098 except Exception as e:
1099 print("Assign Shape Keys: Updating Panel locations has failed", e)
1101 class AssignShapeKeysPreferences(AddonPreferences):
1102 bl_idname = __name__
1104 category: StringProperty(
1105 name = "Tab Category",
1106 description = "Choose a name for the category of the panel",
1107 default = "Edit",
1108 update = updatePanel
1111 def draw(self, context):
1112 layout = self.layout
1113 row = layout.row()
1114 col = row.column()
1115 col.label(text="Tab Category:")
1116 col.prop(self, "category", text="")
1118 # registering and menu integration
1119 def register():
1120 bpy.utils.register_class(AssignShapeKeysPanel)
1121 bpy.utils.register_class(AssignShapeKeysOp)
1122 bpy.utils.register_class(AssignShapeKeyParams)
1123 bpy.types.WindowManager.AssignShapeKeyParams = \
1124 bpy.props.PointerProperty(type=AssignShapeKeyParams)
1125 bpy.utils.register_class(ModalMarkSegStartOp)
1126 bpy.utils.register_class(AssignShapeKeysPreferences)
1127 updatePanel(None, bpy.context)
1129 def unregister():
1130 bpy.utils.unregister_class(AssignShapeKeysOp)
1131 bpy.utils.unregister_class(AssignShapeKeysPanel)
1132 del bpy.types.WindowManager.AssignShapeKeyParams
1133 bpy.utils.unregister_class(AssignShapeKeyParams)
1134 bpy.utils.unregister_class(ModalMarkSegStartOp)
1135 bpy.utils.unregister_class(AssignShapeKeysPreferences)
1137 if __name__ == "__main__":
1138 register()