Fix T77568: turnaround camera crashes undoing
[blender-addons.git] / curve_assign_shapekey.py
blob18e34c0cbf8062649fa3c1412cc3868c6180835a
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 states.append(s.overlay.show_curve_handles)
930 s.overlay.show_curve_handles = False
931 return states
933 def resetShowHandleState(context, handleStates):
934 spaces = MarkerController.getSpaces3D(context)
935 for i, s in enumerate(spaces):
936 s.overlay.show_curve_handles = handleStates[i]
939 class ModalMarkSegStartOp(Operator):
940 bl_description = "Mark Vertex"
941 bl_idname = "wm.mark_vertex"
942 bl_label = "Mark Start Vertex"
944 def cleanup(self, context):
945 wm = context.window_manager
946 wm.event_timer_remove(self._timer)
947 self.markerState.removeMarkers(context)
948 MarkerController.resetShowHandleState(context, self.handleStates)
949 context.window_manager.AssignShapeKeyParams.markVertex = False
951 def modal (self, context, event):
952 params = context.window_manager.AssignShapeKeyParams
954 if(context.mode == 'OBJECT' or event.type == "ESC" or\
955 not context.window_manager.AssignShapeKeyParams.markVertex):
956 self.cleanup(context)
957 return {'CANCELLED'}
959 elif(event.type == "RET"):
960 self.markerState.saveStartVerts()
961 self.cleanup(context)
962 return {'FINISHED'}
964 if(event.type == 'TIMER'):
965 self.markerState.updateSMMap()
966 self.markerState.createBatch(context)
968 elif(event.type in {'LEFT_CTRL', 'RIGHT_CTRL'}):
969 self.ctrl = (event.value == 'PRESS')
971 elif(event.type in {'LEFT_SHIFT', 'RIGHT_SHIFT'}):
972 self.shift = (event.value == 'PRESS')
974 if(event.type not in {"MIDDLEMOUSE", "TAB", "LEFTMOUSE", \
975 "RIGHTMOUSE", 'WHEELDOWNMOUSE', 'WHEELUPMOUSE'} and \
976 not event.type.startswith("NUMPAD_")):
977 return {'RUNNING_MODAL'}
979 return {"PASS_THROUGH"}
981 def execute(self, context):
982 #TODO: Why such small step?
983 self._timer = context.window_manager.event_timer_add(time_step = 0.0001, \
984 window = context.window)
985 self.ctrl = False
986 self.shift = False
988 context.window_manager.modal_handler_add(self)
989 self.markerState = MarkerController(context)
991 #Hide so that users don't accidentally select handles instead of points
992 self.handleStates = MarkerController.hideHandles(context)
994 return {"RUNNING_MODAL"}
997 class AssignShapeKeyParams(bpy.types.PropertyGroup):
999 removeOriginal : BoolProperty(name = "Remove Shape Key Objects", \
1000 description = "Remove shape key objects after assigning to target", \
1001 default = True)
1003 space : EnumProperty(name = "Space", \
1004 items = [('worldspace', 'World Space', 'worldspace'),
1005 ('localspace', 'Local Space', 'localspace')], \
1006 description = 'Space that shape keys are evluated in')
1008 alignCos : EnumProperty(name="Vertex Alignment", items = \
1009 [("-None-", 'Manual Alignment', "Align curve segments based on starting vertex"), \
1010 ('vertCo', 'Vertex Coordinates', 'Align curve segments based on vertex coordinates')], \
1011 description = 'Start aligning the vertices of target and shape keys from',
1012 default = '-None-')
1014 alignVal1 : EnumProperty(name="Value 1",
1015 items = alignList, default = 'minX', description='First align criterion')
1017 alignVal2 : EnumProperty(name="Value 2",
1018 items = alignList, default = 'maxY', description='Second align criterion')
1020 alignVal3 : EnumProperty(name="Value 3",
1021 items = alignList, default = 'minZ', description='Third align criterion')
1023 matchParts : EnumProperty(name="Match Parts", items = \
1024 [("-None-", 'None', "Don't match parts"), \
1025 ('default', 'Default', 'Use part (spline) order as in curve'), \
1026 ('custom', 'Custom', 'Use one of the custom criteria for part matching')], \
1027 description='Match disconnected parts', default = 'default')
1029 matchCri1 : EnumProperty(name="Value 1",
1030 items = matchList, default = 'minX', description='First match criterion')
1032 matchCri2 : EnumProperty(name="Value 2",
1033 items = matchList, default = 'maxY', description='Second match criterion')
1035 matchCri3 : EnumProperty(name="Value 3",
1036 items = matchList, default = 'minZ', description='Third match criterion')
1038 markVertex : BoolProperty(name="Mark Starting Vertices", \
1039 description='Mark first vertices in all splines of selected curves', \
1040 default = False, update = markVertHandler)
1043 class AssignShapeKeysPanel(Panel):
1045 bl_label = "Curve Shape Keys"
1046 bl_idname = "CURVE_PT_assign_shape_keys"
1047 bl_space_type = 'VIEW_3D'
1048 bl_region_type = 'UI'
1049 bl_category = "Edit"
1050 bl_options = {'DEFAULT_CLOSED'}
1052 @classmethod
1053 def poll(cls, context):
1054 return context.mode in {'OBJECT', 'EDIT_CURVE'}
1056 def draw(self, context):
1058 layout = self.layout
1059 layout.label(text='Morph Curves:')
1060 col = layout.column()
1061 params = context.window_manager.AssignShapeKeyParams
1063 if(context.mode == 'OBJECT'):
1064 row = col.row()
1065 row.prop(params, "removeOriginal")
1067 row = col.row()
1068 row.prop(params, "space")
1070 row = col.row()
1071 row.prop(params, "alignCos")
1073 if(params.alignCos == 'vertCo'):
1074 row = col.row()
1075 row.prop(params, "alignVal1")
1076 row.prop(params, "alignVal2")
1077 row.prop(params, "alignVal3")
1079 row = col.row()
1080 row.prop(params, "matchParts")
1082 if(params.matchParts == 'custom'):
1083 row = col.row()
1084 row.prop(params, "matchCri1")
1085 row.prop(params, "matchCri2")
1086 row.prop(params, "matchCri3")
1088 row = col.row()
1089 row.operator("object.assign_shape_keys")
1090 else:
1091 col.prop(params, "markVertex", \
1092 toggle = True)
1095 def updatePanel(self, context):
1096 try:
1097 panel = AssignShapeKeysPanel
1098 if "bl_rna" in panel.__dict__:
1099 bpy.utils.unregister_class(panel)
1101 panel.bl_category = context.preferences.addons[__name__].preferences.category
1102 bpy.utils.register_class(panel)
1104 except Exception as e:
1105 print("Assign Shape Keys: Updating Panel locations has failed", e)
1107 class AssignShapeKeysPreferences(AddonPreferences):
1108 bl_idname = __name__
1110 category: StringProperty(
1111 name = "Tab Category",
1112 description = "Choose a name for the category of the panel",
1113 default = "Edit",
1114 update = updatePanel
1117 def draw(self, context):
1118 layout = self.layout
1119 row = layout.row()
1120 col = row.column()
1121 col.label(text="Tab Category:")
1122 col.prop(self, "category", text="")
1124 # registering and menu integration
1125 def register():
1126 bpy.utils.register_class(AssignShapeKeysPanel)
1127 bpy.utils.register_class(AssignShapeKeysOp)
1128 bpy.utils.register_class(AssignShapeKeyParams)
1129 bpy.types.WindowManager.AssignShapeKeyParams = \
1130 bpy.props.PointerProperty(type=AssignShapeKeyParams)
1131 bpy.utils.register_class(ModalMarkSegStartOp)
1132 bpy.utils.register_class(AssignShapeKeysPreferences)
1133 updatePanel(None, bpy.context)
1135 def unregister():
1136 bpy.utils.unregister_class(AssignShapeKeysOp)
1137 bpy.utils.unregister_class(AssignShapeKeysPanel)
1138 del bpy.types.WindowManager.AssignShapeKeyParams
1139 bpy.utils.unregister_class(AssignShapeKeyParams)
1140 bpy.utils.unregister_class(ModalMarkSegStartOp)
1141 bpy.utils.unregister_class(AssignShapeKeysPreferences)
1143 if __name__ == "__main__":
1144 register()