add_camera_rigs: refactor and cleanup
[blender-addons.git] / curve_assign_shapekey.py
blobf9d8b50107e6a87e8fea83b03be07e063b82335e
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 "location": "View 3D > Sidebar > Edit Tab",
28 "description": "Assigns one or more Bezier curves as shape keys to another Bezier curve",
29 "category": "Add Curve",
30 "wiki_url": "https://docs.blender.org/manual/en/dev/addons/"
31 "add_curve/assign_shape_keys.html",
32 "blender": (2, 80, 0),
35 alignList = [('minX', 'Min X', 'Align vertices with Min X'),
36 ('maxX', 'Max X', 'Align vertices with Max X'),
37 ('minY', 'Min Y', 'Align vertices with Min Y'),
38 ('maxY', 'Max Y', 'Align vertices with Max Y'),
39 ('minZ', 'Min Z', 'Align vertices with Min Z'),
40 ('maxZ', 'Max Z', 'Align vertices with Max Z')]
42 matchList = [('vCnt', 'Vertex Count', 'Match by vertex count'),
43 ('bbArea', 'Area', 'Match by surface area of the bounding box'), \
44 ('bbHeight', 'Height', 'Match by bounding box height'), \
45 ('bbWidth', 'Width', 'Match by bounding box width'),
46 ('bbDepth', 'Depth', 'Match by bounding box depth'),
47 ('minX', 'Min X', 'Match by bounding box Min X'),
48 ('maxX', 'Max X', 'Match by bounding box Max X'),
49 ('minY', 'Min Y', 'Match by bounding box Min Y'),
50 ('maxY', 'Max Y', 'Match by bounding box Max Y'),
51 ('minZ', 'Min Z', 'Match by bounding box Min Z'),
52 ('maxZ', 'Max Z', 'Match by bounding box Max Z')]
54 DEF_ERR_MARGIN = 0.0001
56 def isBezier(obj):
57 return obj.type == 'CURVE' and len(obj.data.splines) > 0 \
58 and obj.data.splines[0].type == 'BEZIER'
60 #Avoid errors due to floating point conversions/comparisons
61 #TODO: return -1, 0, 1
62 def floatCmpWithMargin(float1, float2, margin = DEF_ERR_MARGIN):
63 return abs(float1 - float2) < margin
65 def vectCmpWithMargin(v1, v2, margin = DEF_ERR_MARGIN):
66 return all(floatCmpWithMargin(v1[i], v2[i], margin) for i in range(0, len(v1)))
68 class Segment():
70 #pts[0] - start, pts[1] - ctrl1, pts[2] - ctrl2, , pts[3] - end
71 def pointAtT(pts, t):
72 return pts[0] + t * (3 * (pts[1] - pts[0]) +
73 t* (3 * (pts[0] + pts[2]) - 6 * pts[1] +
74 t * (-pts[0] + 3 * (pts[1] - pts[2]) + pts[3])))
76 def getSegLenRecurs(pts, start, end, t1 = 0, t2 = 1, error = DEF_ERR_MARGIN):
77 t1_5 = (t1 + t2)/2
78 mid = Segment.pointAtT(pts, t1_5)
79 l = (end - start).length
80 l2 = (mid - start).length + (end - mid).length
81 if (l2 - l > error):
82 return (Segment.getSegLenRecurs(pts, start, mid, t1, t1_5, error) +
83 Segment.getSegLenRecurs(pts, mid, end, t1_5, t2, error))
84 return l2
86 def __init__(self, start, ctrl1, ctrl2, end):
87 self.start = start
88 self.ctrl1 = ctrl1
89 self.ctrl2 = ctrl2
90 self.end = end
91 pts = [start, ctrl1, ctrl2, end]
92 self.length = Segment.getSegLenRecurs(pts, start, end)
94 #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
95 def partialSeg(self, t0, t1):
96 pts = [self.start, self.ctrl1, self.ctrl2, self.end]
98 if(t0 > t1):
99 tt = t1
100 t1 = t0
101 t0 = tt
103 #Let's make at least the line segments of predictable length :)
104 if(pts[0] == pts[1] and pts[2] == pts[3]):
105 pt0 = Vector([(1 - t0) * pts[0][i] + t0 * pts[2][i] for i in range(0, 3)])
106 pt1 = Vector([(1 - t1) * pts[0][i] + t1 * pts[2][i] for i in range(0, 3)])
107 return Segment(pt0, pt0, pt1, pt1)
109 u0 = 1.0 - t0
110 u1 = 1.0 - t1
112 qa = [pts[0][i]*u0*u0 + pts[1][i]*2*t0*u0 + pts[2][i]*t0*t0 for i in range(0, 3)]
113 qb = [pts[0][i]*u1*u1 + pts[1][i]*2*t1*u1 + pts[2][i]*t1*t1 for i in range(0, 3)]
114 qc = [pts[1][i]*u0*u0 + pts[2][i]*2*t0*u0 + pts[3][i]*t0*t0 for i in range(0, 3)]
115 qd = [pts[1][i]*u1*u1 + pts[2][i]*2*t1*u1 + pts[3][i]*t1*t1 for i in range(0, 3)]
117 pta = Vector([qa[i]*u0 + qc[i]*t0 for i in range(0, 3)])
118 ptb = Vector([qa[i]*u1 + qc[i]*t1 for i in range(0, 3)])
119 ptc = Vector([qb[i]*u0 + qd[i]*t0 for i in range(0, 3)])
120 ptd = Vector([qb[i]*u1 + qd[i]*t1 for i in range(0, 3)])
122 return Segment(pta, ptb, ptc, ptd)
124 #see https://stackoverflow.com/questions/24809978/calculating-the-bounding-box-of-cubic-bezier-curve
125 #(3 D - 9 C + 9 B - 3 A) t^2 + (6 A - 12 B + 6 C) t + 3 (B - A)
126 #pts[0] - start, pts[1] - ctrl1, pts[2] - ctrl2, , pts[3] - end
127 #TODO: Return Vectors to make world space calculations consistent
128 def bbox(self, mw = None):
129 def evalBez(AA, BB, CC, DD, t):
130 return AA * (1 - t) * (1 - t) * (1 - t) + \
131 3 * BB * t * (1 - t) * (1 - t) + \
132 3 * CC * t * t * (1 - t) + \
133 DD * t * t * t
135 A = self.start
136 B = self.ctrl1
137 C = self.ctrl2
138 D = self.end
140 if(mw != None):
141 A = mw @ A
142 B = mw @ B
143 C = mw @ C
144 D = mw @ D
146 MINXYZ = [min([A[i], D[i]]) for i in range(0, 3)]
147 MAXXYZ = [max([A[i], D[i]]) for i in range(0, 3)]
148 leftBotBack_rgtTopFront = [MINXYZ, MAXXYZ]
150 a = [3 * D[i] - 9 * C[i] + 9 * B[i] - 3 * A[i] for i in range(0, 3)]
151 b = [6 * A[i] - 12 * B[i] + 6 * C[i] for i in range(0, 3)]
152 c = [3 * (B[i] - A[i]) for i in range(0, 3)]
154 solnsxyz = []
155 for i in range(0, 3):
156 solns = []
157 if(a[i] == 0):
158 if(b[i] == 0):
159 solns.append(0)#Independent of t so lets take the starting pt
160 else:
161 solns.append(c[i] / b[i])
162 else:
163 rootFact = b[i] * b[i] - 4 * a[i] * c[i]
164 if(rootFact >=0 ):
165 #Two solutions with + and - sqrt
166 solns.append((-b[i] + sqrt(rootFact)) / (2 * a[i]))
167 solns.append((-b[i] - sqrt(rootFact)) / (2 * a[i]))
168 solnsxyz.append(solns)
170 for i, soln in enumerate(solnsxyz):
171 for j, t in enumerate(soln):
172 if(t < 1 and t > 0):
173 co = evalBez(A[i], B[i], C[i], D[i], t)
174 if(co < leftBotBack_rgtTopFront[0][i]):
175 leftBotBack_rgtTopFront[0][i] = co
176 if(co > leftBotBack_rgtTopFront[1][i]):
177 leftBotBack_rgtTopFront[1][i] = co
179 return leftBotBack_rgtTopFront
182 class Part():
183 def __init__(self, parent, segs, isClosed):
184 self.parent = parent
185 self.segs = segs
187 #use_cyclic_u
188 self.isClosed = isClosed
190 #Indicates if this should be closed based on its counterparts in other paths
191 self.toClose = isClosed
193 self.length = sum(seg.length for seg in self.segs)
194 self.bbox = None
195 self.bboxWorldSpace = None
197 def getSeg(self, idx):
198 return self.segs[idx]
200 def getSegs(self):
201 return self.segs
203 def getSegsCopy(self, start, end):
204 if(start == None):
205 start = 0
206 if(end == None):
207 end = len(self.segs)
208 return self.segs[start:end]
210 def getBBox(self, worldSpace):
211 #Avoid frequent calculations, as this will be called in compare method
212 if(not worldSpace and self.bbox != None):
213 return self.bbox
215 if(worldSpace and self.bboxWorldSpace != None):
216 return self.bboxWorldSpace
218 leftBotBack_rgtTopFront = [[None]*3,[None]*3]
220 for seg in self.segs:
222 if(worldSpace):
223 bb = seg.bbox(self.parent.curve.matrix_world)
224 else:
225 bb = seg.bbox()
227 for i in range(0, 3):
228 if (leftBotBack_rgtTopFront[0][i] == None or \
229 bb[0][i] < leftBotBack_rgtTopFront[0][i]):
230 leftBotBack_rgtTopFront[0][i] = bb[0][i]
232 for i in range(0, 3):
233 if (leftBotBack_rgtTopFront[1][i] == None or \
234 bb[1][i] > leftBotBack_rgtTopFront[1][i]):
235 leftBotBack_rgtTopFront[1][i] = bb[1][i]
237 if(worldSpace):
238 self.bboxWorldSpace = leftBotBack_rgtTopFront
239 else:
240 self.bbox = leftBotBack_rgtTopFront
242 return leftBotBack_rgtTopFront
244 #private
245 def getBBDiff(self, axisIdx, worldSpace):
246 obj = self.parent.curve
247 bbox = self.getBBox(worldSpace)
248 diff = abs(bbox[1][axisIdx] - bbox[0][axisIdx])
249 return diff
251 def getBBWidth(self, worldSpace):
252 return self.getBBDiff(0, worldSpace)
254 def getBBHeight(self, worldSpace):
255 return self.getBBDiff(1, worldSpace)
257 def getBBDepth(self, worldSpace):
258 return self.getBBDiff(2, worldSpace)
260 def bboxSurfaceArea(self, worldSpace):
261 leftBotBack_rgtTopFront = self.getBBox(worldSpace)
262 w = abs( leftBotBack_rgtTopFront[1][0] - leftBotBack_rgtTopFront[0][0] )
263 l = abs( leftBotBack_rgtTopFront[1][1] - leftBotBack_rgtTopFront[0][1] )
264 d = abs( leftBotBack_rgtTopFront[1][2] - leftBotBack_rgtTopFront[0][2] )
266 return 2 * (w * l + w * d + l * d)
268 def getSegCnt(self):
269 return len(self.segs)
271 def getBezierPtsInfo(self):
272 prevSeg = None
273 bezierPtsInfo = []
275 for j, seg in enumerate(self.getSegs()):
277 pt = seg.start
278 handleRight = seg.ctrl1
280 if(j == 0):
281 if(self.toClose):
282 handleLeft = self.getSeg(-1).ctrl2
283 else:
284 handleLeft = pt
285 else:
286 handleLeft = prevSeg.ctrl2
288 bezierPtsInfo.append([pt, handleLeft, handleRight])
289 prevSeg = seg
291 if(self.toClose == True):
292 bezierPtsInfo[-1][2] = seg.ctrl1
293 else:
294 bezierPtsInfo.append([prevSeg.end, prevSeg.ctrl2, prevSeg.end])
296 return bezierPtsInfo
298 def __repr__(self):
299 return str(self.length)
302 class Path:
303 def __init__(self, curve, objData = None, name = None):
305 if(objData == None):
306 objData = curve.data
308 if(name == None):
309 name = curve.name
311 self.name = name
312 self.curve = curve
314 self.parts = [Part(self, getSplineSegs(s), s.use_cyclic_u) for s in objData.splines]
316 def getPartCnt(self):
317 return len(self.parts)
319 def getPartView(self):
320 p = Part(self, [seg for part in self.parts for seg in part.getSegs()], None)
321 return p
323 def getPartBoundaryIdxs(self):
324 cumulCntList = set()
325 cumulCnt = 0
327 for p in self.parts:
328 cumulCnt += p.getSegCnt()
329 cumulCntList.add(cumulCnt)
331 return cumulCntList
333 def updatePartsList(self, segCntsPerPart, byPart):
334 monolithicSegList = [seg for part in self.parts for seg in part.getSegs()]
335 oldParts = self.parts[:]
336 currPart = oldParts[0]
337 partIdx = 0
338 self.parts.clear()
340 for i in range(0, len(segCntsPerPart)):
341 if( i == 0):
342 currIdx = 0
343 else:
344 currIdx = segCntsPerPart[i-1]
346 nextIdx = segCntsPerPart[i]
347 isClosed = False
349 if(vectCmpWithMargin(monolithicSegList[currIdx].start, \
350 currPart.getSegs()[0].start) and \
351 vectCmpWithMargin(monolithicSegList[nextIdx-1].end, \
352 currPart.getSegs()[-1].end)):
353 isClosed = currPart.isClosed
355 self.parts.append(Part(self, \
356 monolithicSegList[currIdx:nextIdx], isClosed))
358 if(monolithicSegList[nextIdx-1] == currPart.getSegs()[-1]):
359 partIdx += 1
360 if(partIdx < len(oldParts)):
361 currPart = oldParts[partIdx]
363 def getBezierPtsBySpline(self):
364 data = []
366 for i, part in enumerate(self.parts):
367 data.append(part.getBezierPtsInfo())
369 return data
371 def getNewCurveData(self):
373 newCurveData = self.curve.data.copy()
374 newCurveData.splines.clear()
376 splinesData = self.getBezierPtsBySpline()
378 for i, newPoints in enumerate(splinesData):
380 spline = newCurveData.splines.new('BEZIER')
381 spline.bezier_points.add(len(newPoints)-1)
382 spline.use_cyclic_u = self.parts[i].toClose
384 for j in range(0, len(spline.bezier_points)):
385 newPoint = newPoints[j]
386 spline.bezier_points[j].co = newPoint[0]
387 spline.bezier_points[j].handle_left = newPoint[1]
388 spline.bezier_points[j].handle_right = newPoint[2]
389 spline.bezier_points[j].handle_right_type = 'FREE'
391 return newCurveData
393 def updateCurve(self):
394 curveData = self.curve.data
395 #Remove existing shape keys first
396 if(curveData.shape_keys != None):
397 keyblocks = reversed(curveData.shape_keys.key_blocks)
398 for sk in keyblocks:
399 self.curve.shape_key_remove(sk)
400 self.curve.data = self.getNewCurveData()
401 bpy.data.curves.remove(curveData)
403 def main(targetObj, shapekeyObjs, removeOriginal, space, matchParts, \
404 matchCriteria, alignBy, alignValues):
406 target = Path(targetObj)
408 shapekeys = [Path(c) for c in shapekeyObjs]
410 existingKeys = getExistingShapeKeyPaths(target)
411 shapekeys = existingKeys + shapekeys
412 userSel = [target] + shapekeys
414 for path in userSel:
415 alignPath(path, matchParts, matchCriteria, alignBy, alignValues)
417 addMissingSegs(userSel, byPart = (matchParts != "-None-"))
419 bIdxs = set()
420 for path in userSel:
421 bIdxs = bIdxs.union(path.getPartBoundaryIdxs())
423 for path in userSel:
424 path.updatePartsList(sorted(list(bIdxs)), byPart = False)
426 #All will have the same part count by now
427 allToClose = [all(path.parts[j].isClosed for path in userSel)
428 for j in range(0, len(userSel[0].parts))]
430 #All paths will have the same no of splines with the same no of bezier points
431 for path in userSel:
432 for j, part in enumerate(path.parts):
433 part.toClose = allToClose[j]
435 target.updateCurve()
437 if(len(existingKeys) == 0):
438 target.curve.shape_key_add(name = 'Basis')
440 addShapeKeys(target.curve, shapekeys, space)
442 if(removeOriginal):
443 for path in userSel:
444 if(path.curve != target.curve):
445 safeRemoveObj(path.curve)
447 def getSplineSegs(spline):
448 p = spline.bezier_points
449 segs = [Segment(p[i-1].co, p[i-1].handle_right, p[i].handle_left, p[i].co) \
450 for i in range(1, len(p))]
451 if(spline.use_cyclic_u):
452 segs.append(Segment(p[-1].co, p[-1].handle_right, p[0].handle_left, p[0].co))
453 return segs
455 def subdivideSeg(origSeg, noSegs):
456 if(noSegs < 2):
457 return [origSeg]
459 segs = []
460 oldT = 0
461 segLen = origSeg.length / noSegs
463 for i in range(0, noSegs-1):
464 t = float(i+1) / noSegs
465 seg = origSeg.partialSeg(oldT, t)
466 segs.append(seg)
467 oldT = t
469 seg = origSeg.partialSeg(oldT, 1)
470 segs.append(seg)
472 return segs
475 def getSubdivCntPerSeg(part, toAddCnt):
477 class SegWrapper:
478 def __init__(self, idx, seg):
479 self.idx = idx
480 self.seg = seg
481 self.length = seg.length
483 class PartWrapper:
484 def __init__(self, part):
485 self.segList = []
486 self.segCnt = len(part.getSegs())
487 for idx, seg in enumerate(part.getSegs()):
488 self.segList.append(SegWrapper(idx, seg))
490 partWrapper = PartWrapper(part)
491 partLen = part.length
492 avgLen = partLen / (partWrapper.segCnt + toAddCnt)
494 segsToDivide = [sr for sr in partWrapper.segList if sr.seg.length >= avgLen]
495 segToDivideCnt = len(segsToDivide)
496 avgLen = sum(sr.seg.length for sr in segsToDivide) / (segToDivideCnt + toAddCnt)
498 segsToDivide = sorted(segsToDivide, key=lambda x: x.length, reverse = True)
500 cnts = [0] * partWrapper.segCnt
501 addedCnt = 0
504 for i in range(0, segToDivideCnt):
505 segLen = segsToDivide[i].seg.length
507 divideCnt = int(round(segLen/avgLen)) - 1
508 if(divideCnt == 0):
509 break
511 if((addedCnt + divideCnt) >= toAddCnt):
512 cnts[segsToDivide[i].idx] = toAddCnt - addedCnt
513 addedCnt = toAddCnt
514 break
516 cnts[segsToDivide[i].idx] = divideCnt
518 addedCnt += divideCnt
520 #TODO: Verify if needed
521 while(toAddCnt > addedCnt):
522 for i in range(0, segToDivideCnt):
523 cnts[segsToDivide[i].idx] += 1
524 addedCnt += 1
525 if(toAddCnt == addedCnt):
526 break
528 return cnts
530 #Just distribute equally; this is likely a rare condition. So why complicate?
531 def distributeCnt(maxSegCntsByPart, startIdx, extraCnt):
532 added = 0
533 elemCnt = len(maxSegCntsByPart) - startIdx
534 cntPerElem = floor(extraCnt / elemCnt)
535 remainder = extraCnt % elemCnt
537 for i in range(startIdx, len(maxSegCntsByPart)):
538 maxSegCntsByPart[i] += cntPerElem
539 if(i < remainder + startIdx):
540 maxSegCntsByPart[i] += 1
542 #Make all the paths to have the maximum number of segments in the set
543 #TODO: Refactor
544 def addMissingSegs(selPaths, byPart):
545 maxSegCntsByPart = []
546 maxSegCnt = 0
548 resSegCnt = []
549 sortedPaths = sorted(selPaths, key = lambda c: -len(c.parts))
551 for i, path in enumerate(sortedPaths):
552 if(byPart == False):
553 segCnt = path.getPartView().getSegCnt()
554 if(segCnt > maxSegCnt):
555 maxSegCnt = segCnt
556 else:
557 resSegCnt.append([])
558 for j, part in enumerate(path.parts):
559 partSegCnt = part.getSegCnt()
560 resSegCnt[i].append(partSegCnt)
562 #First path
563 if(j == len(maxSegCntsByPart)):
564 maxSegCntsByPart.append(partSegCnt)
566 #last part of this path, but other paths in set have more parts
567 elif((j == len(path.parts) - 1) and
568 len(maxSegCntsByPart) > len(path.parts)):
570 remainingSegs = sum(maxSegCntsByPart[j:])
571 if(partSegCnt <= remainingSegs):
572 resSegCnt[i][j] = remainingSegs
573 else:
574 #This part has more segs than the sum of the remaining part segs
575 #So distribute the extra count
576 distributeCnt(maxSegCntsByPart, j, (partSegCnt - remainingSegs))
578 #Also, adjust the seg count of the last part of the previous
579 #segments that had fewer than max number of parts
580 for k in range(0, i):
581 if(len(sortedPaths[k].parts) < len(maxSegCntsByPart)):
582 totalSegs = sum(maxSegCntsByPart)
583 existingSegs = sum(maxSegCntsByPart[:len(sortedPaths[k].parts)-1])
584 resSegCnt[k][-1] = totalSegs - existingSegs
586 elif(partSegCnt > maxSegCntsByPart[j]):
587 maxSegCntsByPart[j] = partSegCnt
588 for i, path in enumerate(sortedPaths):
590 if(byPart == False):
591 partView = path.getPartView()
592 segCnt = partView.getSegCnt()
593 diff = maxSegCnt - segCnt
595 if(diff > 0):
596 cnts = getSubdivCntPerSeg(partView, diff)
597 cumulSegIdx = 0
598 for j in range(0, len(path.parts)):
599 part = path.parts[j]
600 newSegs = []
601 for k, seg in enumerate(part.getSegs()):
602 numSubdivs = cnts[cumulSegIdx] + 1
603 newSegs += subdivideSeg(seg, numSubdivs)
604 cumulSegIdx += 1
606 path.parts[j] = Part(path, newSegs, part.isClosed)
607 else:
608 for j in range(0, len(path.parts)):
609 part = path.parts[j]
610 newSegs = []
612 partSegCnt = part.getSegCnt()
614 #TODO: Adding everything in the last part?
615 if(j == (len(path.parts)-1) and
616 len(maxSegCntsByPart) > len(path.parts)):
617 diff = resSegCnt[i][j] - partSegCnt
618 else:
619 diff = maxSegCntsByPart[j] - partSegCnt
621 if(diff > 0):
622 cnts = getSubdivCntPerSeg(part, diff)
624 for k, seg in enumerate(part.getSegs()):
625 seg = part.getSeg(k)
626 subdivCnt = cnts[k] + 1 #1 for the existing one
627 newSegs += subdivideSeg(seg, subdivCnt)
629 #isClosed won't be used, but let's update anyway
630 path.parts[j] = Part(path, newSegs, part.isClosed)
632 #TODO: Simplify (Not very readable)
633 def alignPath(path, matchParts, matchCriteria, alignBy, alignValues):
635 parts = path.parts[:]
637 if(matchParts == 'custom'):
638 fnMap = {'vCnt' : lambda part: -1 * part.getSegCnt(), \
639 'bbArea': lambda part: -1 * part.bboxSurfaceArea(worldSpace = True), \
640 'bbHeight' : lambda part: -1 * part.getBBHeight(worldSpace = True), \
641 'bbWidth' : lambda part: -1 * part.getBBWidth(worldSpace = True), \
642 'bbDepth' : lambda part: -1 * part.getBBDepth(worldSpace = True)
644 matchPartCmpFns = []
645 for criterion in matchCriteria:
646 fn = fnMap.get(criterion)
647 if(fn == None):
648 minmax = criterion[:3] == 'max' #0 if min; 1 if max
649 axisIdx = ord(criterion[3:]) - ord('X')
651 fn = eval('lambda part: part.getBBox(worldSpace = True)[' + \
652 str(minmax) + '][' + str(axisIdx) + ']')
654 matchPartCmpFns.append(fn)
656 def comparer(left, right):
657 for fn in matchPartCmpFns:
658 a = fn(left)
659 b = fn(right)
661 if(floatCmpWithMargin(a, b)):
662 continue
663 else:
664 return (a > b) - ( a < b) #No cmp in python3
666 return 0
668 parts = sorted(parts, key = cmp_to_key(comparer))
670 alignCmpFn = None
671 if(alignBy == 'vertCo'):
672 def evalCmp(criteria, pt1, pt2):
673 if(len(criteria) == 0):
674 return True
676 minmax = criteria[0][0]
677 axisIdx = criteria[0][1]
678 val1 = pt1[axisIdx]
679 val2 = pt2[axisIdx]
681 if(floatCmpWithMargin(val1, val2)):
682 criteria = criteria[:]
683 criteria.pop(0)
684 return evalCmp(criteria, pt1, pt2)
686 return val1 < val2 if minmax == 'min' else val1 > val2
688 alignCri = [[a[:3], ord(a[3:]) - ord('X')] for a in alignValues]
689 alignCmpFn = lambda pt1, pt2, curve: (evalCmp(alignCri, \
690 curve.matrix_world @ pt1, curve.matrix_world @ pt2))
692 startPt = None
693 startIdx = None
695 for i in range(0, len(parts)):
696 #Only truly closed parts
697 if(alignCmpFn != None and parts[i].isClosed):
698 for j in range(0, parts[i].getSegCnt()):
699 seg = parts[i].getSeg(j)
700 if(j == 0 or alignCmpFn(seg.start, startPt, path.curve)):
701 startPt = seg.start
702 startIdx = j
704 path.parts[i]= Part(path, parts[i].getSegsCopy(startIdx, None) + \
705 parts[i].getSegsCopy(None, startIdx), parts[i].isClosed)
706 else:
707 path.parts[i] = parts[i]
709 #TODO: Other shape key attributes like interpolation...?
710 def getExistingShapeKeyPaths(path):
711 obj = path.curve
712 paths = []
714 if(obj.data.shape_keys != None):
715 keyblocks = obj.data.shape_keys.key_blocks[:]
716 for key in keyblocks:
717 datacopy = obj.data.copy()
718 i = 0
719 for spline in datacopy.splines:
720 for pt in spline.bezier_points:
721 pt.co = key.data[i].co
722 pt.handle_left = key.data[i].handle_left
723 pt.handle_right = key.data[i].handle_right
724 i += 1
725 paths.append(Path(obj, datacopy, key.name))
726 return paths
728 def addShapeKeys(curve, paths, space):
729 for path in paths:
730 key = curve.shape_key_add(name = path.name)
731 pts = [pt for pset in path.getBezierPtsBySpline() for pt in pset]
732 for i, pt in enumerate(pts):
733 if(space == 'worldspace'):
734 pt = [curve.matrix_world.inverted() @ (path.curve.matrix_world @ p) for p in pt]
735 key.data[i].co = pt[0]
736 key.data[i].handle_left = pt[1]
737 key.data[i].handle_right = pt[2]
739 #TODO: Remove try
740 def safeRemoveObj(obj):
741 try:
742 collections = obj.users_collection
744 for c in collections:
745 c.objects.unlink(obj)
747 if(obj.name in bpy.context.scene.collection.objects):
748 bpy.context.scene.collection.objects.unlink(obj)
750 if(obj.data.users == 1):
751 if(obj.type == 'CURVE'):
752 bpy.data.curves.remove(obj.data) #This also removes object?
753 elif(obj.type == 'MESH'):
754 bpy.data.meshes.remove(obj.data)
756 bpy.data.objects.remove(obj)
757 except:
758 pass
761 def markVertHandler(self, context):
762 if(self.markVertex):
763 bpy.ops.wm.mark_vertex()
766 #################### UI and Registration ####################
768 class AssignShapeKeysOp(Operator):
769 bl_idname = "object.assign_shape_keys"
770 bl_label = "Assign Shape Keys"
771 bl_options = {'REGISTER', 'UNDO'}
773 def execute(self, context):
774 params = context.window_manager.AssignShapeKeyParams
775 removeOriginal = params.removeOriginal
776 space = params.space
778 matchParts = params.matchParts
779 matchCri1 = params.matchCri1
780 matchCri2 = params.matchCri2
781 matchCri3 = params.matchCri3
783 alignBy = params.alignCos
784 alignVal1 = params.alignVal1
785 alignVal2 = params.alignVal2
786 alignVal3 = params.alignVal3
788 targetObj = bpy.context.active_object
789 shapekeyObjs = [obj for obj in bpy.context.selected_objects if isBezier(obj) \
790 and obj != targetObj]
792 if(targetObj != None and isBezier(targetObj) and len(shapekeyObjs) > 0):
793 main(targetObj, shapekeyObjs, removeOriginal, space, \
794 matchParts, [matchCri1, matchCri2, matchCri3], \
795 alignBy, [alignVal1, alignVal2, alignVal3])
797 return {'FINISHED'}
800 class MarkerController:
801 drawHandlerRef = None
802 defPointSize = 6
803 ptColor = (0, .8, .8, 1)
805 def createSMMap(self, context):
806 objs = context.selected_objects
807 smMap = {}
808 for curve in objs:
809 if(not isBezier(curve)):
810 continue
812 smMap[curve.name] = {}
813 mw = curve.matrix_world
814 for splineIdx, spline in enumerate(curve.data.splines):
815 if(not spline.use_cyclic_u):
816 continue
818 #initialize to the curr start vert co and idx
819 smMap[curve.name][splineIdx] = \
820 [mw @ curve.data.splines[splineIdx].bezier_points[0].co, 0]
822 for pt in spline.bezier_points:
823 pt.select_control_point = False
825 if(len(smMap[curve.name]) == 0):
826 del smMap[curve.name]
828 return smMap
830 def createBatch(self, context):
831 positions = [s[0] for cn in self.smMap.values() for s in cn.values()]
832 colors = [MarkerController.ptColor for i in range(0, len(positions))]
834 self.batch = batch_for_shader(self.shader, \
835 "POINTS", {"pos": positions, "color": colors})
837 if context.area:
838 context.area.tag_redraw()
840 def drawHandler(self):
841 bgl.glPointSize(MarkerController.defPointSize)
842 self.batch.draw(self.shader)
844 def removeMarkers(self, context):
845 if(MarkerController.drawHandlerRef != None):
846 bpy.types.SpaceView3D.draw_handler_remove(MarkerController.drawHandlerRef, \
847 "WINDOW")
849 if(context.area and hasattr(context.space_data, 'region_3d')):
850 context.area.tag_redraw()
852 MarkerController.drawHandlerRef = None
854 self.deselectAll()
856 def __init__(self, context):
857 self.smMap = self.createSMMap(context)
858 self.shader = gpu.shader.from_builtin('3D_FLAT_COLOR')
859 self.shader.bind()
861 MarkerController.drawHandlerRef = \
862 bpy.types.SpaceView3D.draw_handler_add(self.drawHandler, \
863 (), "WINDOW", "POST_VIEW")
865 self.createBatch(context)
867 def saveStartVerts(self):
868 for curveName in self.smMap.keys():
869 curve = bpy.data.objects[curveName]
870 splines = curve.data.splines
871 spMap = self.smMap[curveName]
873 for splineIdx in spMap.keys():
874 markerInfo = spMap[splineIdx]
875 if(markerInfo[1] != 0):
876 pts = splines[splineIdx].bezier_points
877 loc, idx = markerInfo[0], markerInfo[1]
878 cnt = len(pts)
880 ptCopy = [[p.co.copy(), p.handle_right.copy(), \
881 p.handle_left.copy(), p.handle_right_type, \
882 p.handle_left_type] for p in pts]
884 for i, pt in enumerate(pts):
885 srcIdx = (idx + i) % cnt
886 p = ptCopy[srcIdx]
888 #Must set the types first
889 pt.handle_right_type = p[3]
890 pt.handle_left_type = p[4]
891 pt.co = p[0]
892 pt.handle_right = p[1]
893 pt.handle_left = p[2]
895 def updateSMMap(self):
896 for curveName in self.smMap.keys():
897 curve = bpy.data.objects[curveName]
898 spMap = self.smMap[curveName]
899 mw = curve.matrix_world
901 for splineIdx in spMap.keys():
902 markerInfo = spMap[splineIdx]
903 loc, idx = markerInfo[0], markerInfo[1]
904 pts = curve.data.splines[splineIdx].bezier_points
906 selIdxs = [x for x in range(0, len(pts)) \
907 if pts[x].select_control_point == True]
909 selIdx = selIdxs[0] if(len(selIdxs) > 0 ) else idx
910 co = mw @ pts[selIdx].co
911 self.smMap[curveName][splineIdx] = [co, selIdx]
913 def deselectAll(self):
914 for curveName in self.smMap.keys():
915 curve = bpy.data.objects[curveName]
916 for spline in curve.data.splines:
917 for pt in spline.bezier_points:
918 pt.select_control_point = False
920 def getSpaces3D(context):
921 areas3d = [area for area in context.window.screen.areas \
922 if area.type == 'VIEW_3D']
924 return [s for a in areas3d for s in a.spaces if s.type == 'VIEW_3D']
926 def hideHandles(context):
927 states = []
928 spaces = MarkerController.getSpaces3D(context)
929 for s in spaces:
930 states.append(s.overlay.show_curve_handles)
931 s.overlay.show_curve_handles = False
932 return states
934 def resetShowHandleState(context, handleStates):
935 spaces = MarkerController.getSpaces3D(context)
936 for i, s in enumerate(spaces):
937 s.overlay.show_curve_handles = handleStates[i]
940 class ModalMarkSegStartOp(Operator):
941 bl_description = "Mark Vertex"
942 bl_idname = "wm.mark_vertex"
943 bl_label = "Mark Start Vertex"
945 def cleanup(self, context):
946 wm = context.window_manager
947 wm.event_timer_remove(self._timer)
948 self.markerState.removeMarkers(context)
949 MarkerController.resetShowHandleState(context, self.handleStates)
950 context.window_manager.AssignShapeKeyParams.markVertex = False
952 def modal (self, context, event):
953 params = context.window_manager.AssignShapeKeyParams
955 if(context.mode == 'OBJECT' or event.type == "ESC" or\
956 not context.window_manager.AssignShapeKeyParams.markVertex):
957 self.cleanup(context)
958 return {'CANCELLED'}
960 elif(event.type == "RET"):
961 self.markerState.saveStartVerts()
962 self.cleanup(context)
963 return {'FINISHED'}
965 if(event.type == 'TIMER'):
966 self.markerState.updateSMMap()
967 self.markerState.createBatch(context)
969 elif(event.type in {'LEFT_CTRL', 'RIGHT_CTRL'}):
970 self.ctrl = (event.value == 'PRESS')
972 elif(event.type in {'LEFT_SHIFT', 'RIGHT_SHIFT'}):
973 self.shift = (event.value == 'PRESS')
975 if(event.type not in {"MIDDLEMOUSE", "TAB", "LEFTMOUSE", \
976 "RIGHTMOUSE", 'WHEELDOWNMOUSE', 'WHEELUPMOUSE'} and \
977 not event.type.startswith("NUMPAD_")):
978 return {'RUNNING_MODAL'}
980 return {"PASS_THROUGH"}
982 def execute(self, context):
983 #TODO: Why such small step?
984 self._timer = context.window_manager.event_timer_add(time_step = 0.0001, \
985 window = context.window)
986 self.ctrl = False
987 self.shift = False
989 context.window_manager.modal_handler_add(self)
990 self.markerState = MarkerController(context)
992 #Hide so that users don't accidentally select handles instead of points
993 self.handleStates = MarkerController.hideHandles(context)
995 return {"RUNNING_MODAL"}
998 class AssignShapeKeyParams(bpy.types.PropertyGroup):
1000 removeOriginal : BoolProperty(name = "Remove Shape Key Objects", \
1001 description = "Remove shape key objects after assigning to target", \
1002 default = True)
1004 space : EnumProperty(name = "Space", \
1005 items = [('worldspace', 'World Space', 'worldspace'),
1006 ('localspace', 'Local Space', 'localspace')], \
1007 description = 'Space that shape keys are evluated in')
1009 alignCos : EnumProperty(name="Vertex Alignment", items = \
1010 [("-None-", 'Manual Alignment', "Align curve segments based on starting vertex"), \
1011 ('vertCo', 'Vertex Coordinates', 'Align curve segments based on vertex coordinates')], \
1012 description = 'Start aligning the vertices of target and shape keys from',
1013 default = '-None-')
1015 alignVal1 : EnumProperty(name="Value 1",
1016 items = alignList, default = 'minX', description='First align criterion')
1018 alignVal2 : EnumProperty(name="Value 2",
1019 items = alignList, default = 'maxY', description='Second align criterion')
1021 alignVal3 : EnumProperty(name="Value 3",
1022 items = alignList, default = 'minZ', description='Third align criterion')
1024 matchParts : EnumProperty(name="Match Parts", items = \
1025 [("-None-", 'None', "Don't match parts"), \
1026 ('default', 'Default', 'Use part (spline) order as in curve'), \
1027 ('custom', 'Custom', 'Use one of the custom criteria for part matching')], \
1028 description='Match disconnected parts', default = 'default')
1030 matchCri1 : EnumProperty(name="Value 1",
1031 items = matchList, default = 'minX', description='First match criterion')
1033 matchCri2 : EnumProperty(name="Value 2",
1034 items = matchList, default = 'maxY', description='Second match criterion')
1036 matchCri3 : EnumProperty(name="Value 3",
1037 items = matchList, default = 'minZ', description='Third match criterion')
1039 markVertex : BoolProperty(name="Mark Starting Vertices", \
1040 description='Mark first vertices in all splines of selected curves', \
1041 default = False, update = markVertHandler)
1044 class AssignShapeKeysPanel(Panel):
1046 bl_label = "Curve Shape Keys"
1047 bl_idname = "CURVE_PT_assign_shape_keys"
1048 bl_space_type = 'VIEW_3D'
1049 bl_region_type = 'UI'
1050 bl_category = "Edit"
1051 bl_options = {'DEFAULT_CLOSED'}
1053 @classmethod
1054 def poll(cls, context):
1055 return context.mode in {'OBJECT', 'EDIT_CURVE'}
1057 def draw(self, context):
1059 layout = self.layout
1060 layout.label(text='Morph Curves:')
1061 col = layout.column()
1062 params = context.window_manager.AssignShapeKeyParams
1064 if(context.mode == 'OBJECT'):
1065 row = col.row()
1066 row.prop(params, "removeOriginal")
1068 row = col.row()
1069 row.prop(params, "space")
1071 row = col.row()
1072 row.prop(params, "alignCos")
1074 if(params.alignCos == 'vertCo'):
1075 row = col.row()
1076 row.prop(params, "alignVal1")
1077 row.prop(params, "alignVal2")
1078 row.prop(params, "alignVal3")
1080 row = col.row()
1081 row.prop(params, "matchParts")
1083 if(params.matchParts == 'custom'):
1084 row = col.row()
1085 row.prop(params, "matchCri1")
1086 row.prop(params, "matchCri2")
1087 row.prop(params, "matchCri3")
1089 row = col.row()
1090 row.operator("object.assign_shape_keys")
1091 else:
1092 col.prop(params, "markVertex", \
1093 toggle = True)
1096 def updatePanel(self, context):
1097 try:
1098 panel = AssignShapeKeysPanel
1099 if "bl_rna" in panel.__dict__:
1100 bpy.utils.unregister_class(panel)
1102 panel.bl_category = context.preferences.addons[__name__].preferences.category
1103 bpy.utils.register_class(panel)
1105 except Exception as e:
1106 print("Assign Shape Keys: Updating Panel locations has failed", e)
1108 class AssignShapeKeysPreferences(AddonPreferences):
1109 bl_idname = __name__
1111 category: StringProperty(
1112 name = "Tab Category",
1113 description = "Choose a name for the category of the panel",
1114 default = "Edit",
1115 update = updatePanel
1118 def draw(self, context):
1119 layout = self.layout
1120 row = layout.row()
1121 col = row.column()
1122 col.label(text="Tab Category:")
1123 col.prop(self, "category", text="")
1125 # registering and menu integration
1126 def register():
1127 bpy.utils.register_class(AssignShapeKeysPanel)
1128 bpy.utils.register_class(AssignShapeKeysOp)
1129 bpy.utils.register_class(AssignShapeKeyParams)
1130 bpy.types.WindowManager.AssignShapeKeyParams = \
1131 bpy.props.PointerProperty(type=AssignShapeKeyParams)
1132 bpy.utils.register_class(ModalMarkSegStartOp)
1133 bpy.utils.register_class(AssignShapeKeysPreferences)
1134 updatePanel(None, bpy.context)
1136 def unregister():
1137 bpy.utils.unregister_class(AssignShapeKeysOp)
1138 bpy.utils.unregister_class(AssignShapeKeysPanel)
1139 del bpy.types.WindowManager.AssignShapeKeyParams
1140 bpy.utils.unregister_class(AssignShapeKeyParams)
1141 bpy.utils.unregister_class(ModalMarkSegStartOp)
1142 bpy.utils.unregister_class(AssignShapeKeysPreferences)
1144 if __name__ == "__main__":
1145 register()