Fix: Node Wrangler: new reroute locations on hidden nodes
[blender-addons.git] / curve_assign_shapekey.py
blob304be5635e2efdf56a6ecdecb8f488fc1e3d19ae
1 # SPDX-FileCopyrightText: 2019 Shrinivas Kulkarni
3 # SPDX-License-Identifier: GPL-3.0-or-later
5 # This Blender add-on assigns one or more Bezier Curves as shape keys to another
6 # Bezier Curve
8 # Supported Blender Versions: 2.8x
10 # https://github.com/Shriinivas/assignshapekey/blob/master/LICENSE
12 import bpy, bmesh, gpu
13 from gpu_extras.batch import batch_for_shader
14 from bpy.props import BoolProperty, EnumProperty, StringProperty
15 from collections import OrderedDict
16 from mathutils import Vector
17 from math import sqrt, floor
18 from functools import cmp_to_key
19 from bpy.types import Panel, Operator, AddonPreferences
22 bl_info = {
23 "name": "Assign Shape Keys",
24 "author": "Shrinivas Kulkarni",
25 "version": (1, 0, 2),
26 "blender": (3, 0, 0),
27 "location": "View 3D > Sidebar > Edit Tab",
28 "description": "Assigns one or more Bezier curves as shape keys to another Bezier curve",
29 "doc_url": "{BLENDER_MANUAL_URL}/addons/add_curve/assign_shape_keys.html",
30 "category": "Add Curve",
33 alignList = [('minX', 'Min X', 'Align vertices with Min X'),
34 ('maxX', 'Max X', 'Align vertices with Max X'),
35 ('minY', 'Min Y', 'Align vertices with Min Y'),
36 ('maxY', 'Max Y', 'Align vertices with Max Y'),
37 ('minZ', 'Min Z', 'Align vertices with Min Z'),
38 ('maxZ', 'Max Z', 'Align vertices with Max Z')]
40 matchList = [('vCnt', 'Vertex Count', 'Match by vertex count'),
41 ('bbArea', 'Area', 'Match by surface area of the bounding box'), \
42 ('bbHeight', 'Height', 'Match by bounding box height'), \
43 ('bbWidth', 'Width', 'Match by bounding box width'),
44 ('bbDepth', 'Depth', 'Match by bounding box depth'),
45 ('minX', 'Min X', 'Match by bounding box Min X'),
46 ('maxX', 'Max X', 'Match by bounding box Max X'),
47 ('minY', 'Min Y', 'Match by bounding box Min Y'),
48 ('maxY', 'Max Y', 'Match by bounding box Max Y'),
49 ('minZ', 'Min Z', 'Match by bounding box Min Z'),
50 ('maxZ', 'Max Z', 'Match by bounding box Max Z')]
52 DEF_ERR_MARGIN = 0.0001
54 def isBezier(obj):
55 return obj.type == 'CURVE' and len(obj.data.splines) > 0 \
56 and obj.data.splines[0].type == 'BEZIER'
58 #Avoid errors due to floating point conversions/comparisons
59 #TODO: return -1, 0, 1
60 def floatCmpWithMargin(float1, float2, margin = DEF_ERR_MARGIN):
61 return abs(float1 - float2) < margin
63 def vectCmpWithMargin(v1, v2, margin = DEF_ERR_MARGIN):
64 return all(floatCmpWithMargin(v1[i], v2[i], margin) for i in range(0, len(v1)))
66 class Segment():
68 #pts[0] - start, pts[1] - ctrl1, pts[2] - ctrl2, , pts[3] - end
69 def pointAtT(pts, t):
70 return pts[0] + t * (3 * (pts[1] - pts[0]) +
71 t* (3 * (pts[0] + pts[2]) - 6 * pts[1] +
72 t * (-pts[0] + 3 * (pts[1] - pts[2]) + pts[3])))
74 def getSegLenRecurs(pts, start, end, t1 = 0, t2 = 1, error = DEF_ERR_MARGIN):
75 t1_5 = (t1 + t2)/2
76 mid = Segment.pointAtT(pts, t1_5)
77 l = (end - start).length
78 l2 = (mid - start).length + (end - mid).length
79 if (l2 - l > error):
80 return (Segment.getSegLenRecurs(pts, start, mid, t1, t1_5, error) +
81 Segment.getSegLenRecurs(pts, mid, end, t1_5, t2, error))
82 return l2
84 def __init__(self, start, ctrl1, ctrl2, end):
85 self.start = start
86 self.ctrl1 = ctrl1
87 self.ctrl2 = ctrl2
88 self.end = end
89 pts = [start, ctrl1, ctrl2, end]
90 self.length = Segment.getSegLenRecurs(pts, start, end)
92 #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
93 def partialSeg(self, t0, t1):
94 pts = [self.start, self.ctrl1, self.ctrl2, self.end]
96 if(t0 > t1):
97 tt = t1
98 t1 = t0
99 t0 = tt
101 #Let's make at least the line segments of predictable length :)
102 if(pts[0] == pts[1] and pts[2] == pts[3]):
103 pt0 = Vector([(1 - t0) * pts[0][i] + t0 * pts[2][i] for i in range(0, 3)])
104 pt1 = Vector([(1 - t1) * pts[0][i] + t1 * pts[2][i] for i in range(0, 3)])
105 return Segment(pt0, pt0, pt1, pt1)
107 u0 = 1.0 - t0
108 u1 = 1.0 - t1
110 qa = [pts[0][i]*u0*u0 + pts[1][i]*2*t0*u0 + pts[2][i]*t0*t0 for i in range(0, 3)]
111 qb = [pts[0][i]*u1*u1 + pts[1][i]*2*t1*u1 + pts[2][i]*t1*t1 for i in range(0, 3)]
112 qc = [pts[1][i]*u0*u0 + pts[2][i]*2*t0*u0 + pts[3][i]*t0*t0 for i in range(0, 3)]
113 qd = [pts[1][i]*u1*u1 + pts[2][i]*2*t1*u1 + pts[3][i]*t1*t1 for i in range(0, 3)]
115 pta = Vector([qa[i]*u0 + qc[i]*t0 for i in range(0, 3)])
116 ptb = Vector([qa[i]*u1 + qc[i]*t1 for i in range(0, 3)])
117 ptc = Vector([qb[i]*u0 + qd[i]*t0 for i in range(0, 3)])
118 ptd = Vector([qb[i]*u1 + qd[i]*t1 for i in range(0, 3)])
120 return Segment(pta, ptb, ptc, ptd)
122 #see https://stackoverflow.com/questions/24809978/calculating-the-bounding-box-of-cubic-bezier-curve
123 #(3 D - 9 C + 9 B - 3 A) t^2 + (6 A - 12 B + 6 C) t + 3 (B - A)
124 #pts[0] - start, pts[1] - ctrl1, pts[2] - ctrl2, , pts[3] - end
125 #TODO: Return Vectors to make world space calculations consistent
126 def bbox(self, mw = None):
127 def evalBez(AA, BB, CC, DD, t):
128 return AA * (1 - t) * (1 - t) * (1 - t) + \
129 3 * BB * t * (1 - t) * (1 - t) + \
130 3 * CC * t * t * (1 - t) + \
131 DD * t * t * t
133 A = self.start
134 B = self.ctrl1
135 C = self.ctrl2
136 D = self.end
138 if(mw != None):
139 A = mw @ A
140 B = mw @ B
141 C = mw @ C
142 D = mw @ D
144 MINXYZ = [min([A[i], D[i]]) for i in range(0, 3)]
145 MAXXYZ = [max([A[i], D[i]]) for i in range(0, 3)]
146 leftBotBack_rgtTopFront = [MINXYZ, MAXXYZ]
148 a = [3 * D[i] - 9 * C[i] + 9 * B[i] - 3 * A[i] for i in range(0, 3)]
149 b = [6 * A[i] - 12 * B[i] + 6 * C[i] for i in range(0, 3)]
150 c = [3 * (B[i] - A[i]) for i in range(0, 3)]
152 solnsxyz = []
153 for i in range(0, 3):
154 solns = []
155 if(a[i] == 0):
156 if(b[i] == 0):
157 solns.append(0)#Independent of t so lets take the starting pt
158 else:
159 solns.append(c[i] / b[i])
160 else:
161 rootFact = b[i] * b[i] - 4 * a[i] * c[i]
162 if(rootFact >=0 ):
163 #Two solutions with + and - sqrt
164 solns.append((-b[i] + sqrt(rootFact)) / (2 * a[i]))
165 solns.append((-b[i] - sqrt(rootFact)) / (2 * a[i]))
166 solnsxyz.append(solns)
168 for i, soln in enumerate(solnsxyz):
169 for j, t in enumerate(soln):
170 if(t < 1 and t > 0):
171 co = evalBez(A[i], B[i], C[i], D[i], t)
172 if(co < leftBotBack_rgtTopFront[0][i]):
173 leftBotBack_rgtTopFront[0][i] = co
174 if(co > leftBotBack_rgtTopFront[1][i]):
175 leftBotBack_rgtTopFront[1][i] = co
177 return leftBotBack_rgtTopFront
180 class Part():
181 def __init__(self, parent, segs, isClosed):
182 self.parent = parent
183 self.segs = segs
185 #use_cyclic_u
186 self.isClosed = isClosed
188 #Indicates if this should be closed based on its counterparts in other paths
189 self.toClose = isClosed
191 self.length = sum(seg.length for seg in self.segs)
192 self.bbox = None
193 self.bboxWorldSpace = None
195 def getSeg(self, idx):
196 return self.segs[idx]
198 def getSegs(self):
199 return self.segs
201 def getSegsCopy(self, start, end):
202 if(start == None):
203 start = 0
204 if(end == None):
205 end = len(self.segs)
206 return self.segs[start:end]
208 def getBBox(self, worldSpace):
209 #Avoid frequent calculations, as this will be called in compare method
210 if(not worldSpace and self.bbox != None):
211 return self.bbox
213 if(worldSpace and self.bboxWorldSpace != None):
214 return self.bboxWorldSpace
216 leftBotBack_rgtTopFront = [[None]*3,[None]*3]
218 for seg in self.segs:
220 if(worldSpace):
221 bb = seg.bbox(self.parent.curve.matrix_world)
222 else:
223 bb = seg.bbox()
225 for i in range(0, 3):
226 if (leftBotBack_rgtTopFront[0][i] == None or \
227 bb[0][i] < leftBotBack_rgtTopFront[0][i]):
228 leftBotBack_rgtTopFront[0][i] = bb[0][i]
230 for i in range(0, 3):
231 if (leftBotBack_rgtTopFront[1][i] == None or \
232 bb[1][i] > leftBotBack_rgtTopFront[1][i]):
233 leftBotBack_rgtTopFront[1][i] = bb[1][i]
235 if(worldSpace):
236 self.bboxWorldSpace = leftBotBack_rgtTopFront
237 else:
238 self.bbox = leftBotBack_rgtTopFront
240 return leftBotBack_rgtTopFront
242 #private
243 def getBBDiff(self, axisIdx, worldSpace):
244 obj = self.parent.curve
245 bbox = self.getBBox(worldSpace)
246 diff = abs(bbox[1][axisIdx] - bbox[0][axisIdx])
247 return diff
249 def getBBWidth(self, worldSpace):
250 return self.getBBDiff(0, worldSpace)
252 def getBBHeight(self, worldSpace):
253 return self.getBBDiff(1, worldSpace)
255 def getBBDepth(self, worldSpace):
256 return self.getBBDiff(2, worldSpace)
258 def bboxSurfaceArea(self, worldSpace):
259 leftBotBack_rgtTopFront = self.getBBox(worldSpace)
260 w = abs( leftBotBack_rgtTopFront[1][0] - leftBotBack_rgtTopFront[0][0] )
261 l = abs( leftBotBack_rgtTopFront[1][1] - leftBotBack_rgtTopFront[0][1] )
262 d = abs( leftBotBack_rgtTopFront[1][2] - leftBotBack_rgtTopFront[0][2] )
264 return 2 * (w * l + w * d + l * d)
266 def getSegCnt(self):
267 return len(self.segs)
269 def getBezierPtsInfo(self):
270 prevSeg = None
271 bezierPtsInfo = []
273 for j, seg in enumerate(self.getSegs()):
275 pt = seg.start
276 handleRight = seg.ctrl1
278 if(j == 0):
279 if(self.toClose):
280 handleLeft = self.getSeg(-1).ctrl2
281 else:
282 handleLeft = pt
283 else:
284 handleLeft = prevSeg.ctrl2
286 bezierPtsInfo.append([pt, handleLeft, handleRight])
287 prevSeg = seg
289 if(self.toClose == True):
290 bezierPtsInfo[-1][2] = seg.ctrl1
291 else:
292 bezierPtsInfo.append([prevSeg.end, prevSeg.ctrl2, prevSeg.end])
294 return bezierPtsInfo
296 def __repr__(self):
297 return str(self.length)
300 class Path:
301 def __init__(self, curve, objData = None, name = None):
303 if(objData == None):
304 objData = curve.data
306 if(name == None):
307 name = curve.name
309 self.name = name
310 self.curve = curve
312 self.parts = [Part(self, getSplineSegs(s), s.use_cyclic_u) for s in objData.splines]
314 def getPartCnt(self):
315 return len(self.parts)
317 def getPartView(self):
318 p = Part(self, [seg for part in self.parts for seg in part.getSegs()], None)
319 return p
321 def getPartBoundaryIdxs(self):
322 cumulCntList = set()
323 cumulCnt = 0
325 for p in self.parts:
326 cumulCnt += p.getSegCnt()
327 cumulCntList.add(cumulCnt)
329 return cumulCntList
331 def updatePartsList(self, segCntsPerPart, byPart):
332 monolithicSegList = [seg for part in self.parts for seg in part.getSegs()]
333 oldParts = self.parts[:]
334 currPart = oldParts[0]
335 partIdx = 0
336 self.parts.clear()
338 for i in range(0, len(segCntsPerPart)):
339 if( i == 0):
340 currIdx = 0
341 else:
342 currIdx = segCntsPerPart[i-1]
344 nextIdx = segCntsPerPart[i]
345 isClosed = False
347 if(vectCmpWithMargin(monolithicSegList[currIdx].start, \
348 currPart.getSegs()[0].start) and \
349 vectCmpWithMargin(monolithicSegList[nextIdx-1].end, \
350 currPart.getSegs()[-1].end)):
351 isClosed = currPart.isClosed
353 self.parts.append(Part(self, \
354 monolithicSegList[currIdx:nextIdx], isClosed))
356 if(monolithicSegList[nextIdx-1] == currPart.getSegs()[-1]):
357 partIdx += 1
358 if(partIdx < len(oldParts)):
359 currPart = oldParts[partIdx]
361 def getBezierPtsBySpline(self):
362 data = []
364 for i, part in enumerate(self.parts):
365 data.append(part.getBezierPtsInfo())
367 return data
369 def getNewCurveData(self):
371 newCurveData = self.curve.data.copy()
372 newCurveData.splines.clear()
374 splinesData = self.getBezierPtsBySpline()
376 for i, newPoints in enumerate(splinesData):
378 spline = newCurveData.splines.new('BEZIER')
379 spline.bezier_points.add(len(newPoints)-1)
380 spline.use_cyclic_u = self.parts[i].toClose
382 for j in range(0, len(spline.bezier_points)):
383 newPoint = newPoints[j]
384 spline.bezier_points[j].co = newPoint[0]
385 spline.bezier_points[j].handle_left = newPoint[1]
386 spline.bezier_points[j].handle_right = newPoint[2]
387 spline.bezier_points[j].handle_right_type = 'FREE'
389 return newCurveData
391 def updateCurve(self):
392 curveData = self.curve.data
393 #Remove existing shape keys first
394 if(curveData.shape_keys != None):
395 keyblocks = reversed(curveData.shape_keys.key_blocks)
396 for sk in keyblocks:
397 self.curve.shape_key_remove(sk)
398 self.curve.data = self.getNewCurveData()
399 bpy.data.curves.remove(curveData)
401 def main(targetObj, shapekeyObjs, removeOriginal, space, matchParts, \
402 matchCriteria, alignBy, alignValues):
404 target = Path(targetObj)
406 shapekeys = [Path(c) for c in shapekeyObjs]
408 existingKeys = getExistingShapeKeyPaths(target)
409 shapekeys = existingKeys + shapekeys
410 userSel = [target] + shapekeys
412 for path in userSel:
413 alignPath(path, matchParts, matchCriteria, alignBy, alignValues)
415 addMissingSegs(userSel, byPart = (matchParts != "-None-"))
417 bIdxs = set()
418 for path in userSel:
419 bIdxs = bIdxs.union(path.getPartBoundaryIdxs())
421 for path in userSel:
422 path.updatePartsList(sorted(list(bIdxs)), byPart = False)
424 #All will have the same part count by now
425 allToClose = [all(path.parts[j].isClosed for path in userSel)
426 for j in range(0, len(userSel[0].parts))]
428 #All paths will have the same no of splines with the same no of bezier points
429 for path in userSel:
430 for j, part in enumerate(path.parts):
431 part.toClose = allToClose[j]
433 target.updateCurve()
435 if(len(existingKeys) == 0):
436 target.curve.shape_key_add(name = 'Basis')
438 addShapeKeys(target.curve, shapekeys, space)
440 if(removeOriginal):
441 for path in userSel:
442 if(path.curve != target.curve):
443 safeRemoveObj(path.curve)
445 def getSplineSegs(spline):
446 p = spline.bezier_points
447 segs = [Segment(p[i-1].co, p[i-1].handle_right, p[i].handle_left, p[i].co) \
448 for i in range(1, len(p))]
449 if(spline.use_cyclic_u):
450 segs.append(Segment(p[-1].co, p[-1].handle_right, p[0].handle_left, p[0].co))
451 return segs
453 def subdivideSeg(origSeg, noSegs):
454 if(noSegs < 2):
455 return [origSeg]
457 segs = []
458 oldT = 0
459 segLen = origSeg.length / noSegs
461 for i in range(0, noSegs-1):
462 t = float(i+1) / noSegs
463 seg = origSeg.partialSeg(oldT, t)
464 segs.append(seg)
465 oldT = t
467 seg = origSeg.partialSeg(oldT, 1)
468 segs.append(seg)
470 return segs
473 def getSubdivCntPerSeg(part, toAddCnt):
475 class SegWrapper:
476 def __init__(self, idx, seg):
477 self.idx = idx
478 self.seg = seg
479 self.length = seg.length
481 class PartWrapper:
482 def __init__(self, part):
483 self.segList = []
484 self.segCnt = len(part.getSegs())
485 for idx, seg in enumerate(part.getSegs()):
486 self.segList.append(SegWrapper(idx, seg))
488 partWrapper = PartWrapper(part)
489 partLen = part.length
490 avgLen = partLen / (partWrapper.segCnt + toAddCnt)
492 segsToDivide = [sr for sr in partWrapper.segList if sr.seg.length >= avgLen]
493 segToDivideCnt = len(segsToDivide)
494 avgLen = sum(sr.seg.length for sr in segsToDivide) / (segToDivideCnt + toAddCnt)
496 segsToDivide = sorted(segsToDivide, key=lambda x: x.length, reverse = True)
498 cnts = [0] * partWrapper.segCnt
499 addedCnt = 0
502 for i in range(0, segToDivideCnt):
503 segLen = segsToDivide[i].seg.length
505 divideCnt = int(round(segLen/avgLen)) - 1
506 if(divideCnt == 0):
507 break
509 if((addedCnt + divideCnt) >= toAddCnt):
510 cnts[segsToDivide[i].idx] = toAddCnt - addedCnt
511 addedCnt = toAddCnt
512 break
514 cnts[segsToDivide[i].idx] = divideCnt
516 addedCnt += divideCnt
518 #TODO: Verify if needed
519 while(toAddCnt > addedCnt):
520 for i in range(0, segToDivideCnt):
521 cnts[segsToDivide[i].idx] += 1
522 addedCnt += 1
523 if(toAddCnt == addedCnt):
524 break
526 return cnts
528 #Just distribute equally; this is likely a rare condition. So why complicate?
529 def distributeCnt(maxSegCntsByPart, startIdx, extraCnt):
530 added = 0
531 elemCnt = len(maxSegCntsByPart) - startIdx
532 cntPerElem = floor(extraCnt / elemCnt)
533 remainder = extraCnt % elemCnt
535 for i in range(startIdx, len(maxSegCntsByPart)):
536 maxSegCntsByPart[i] += cntPerElem
537 if(i < remainder + startIdx):
538 maxSegCntsByPart[i] += 1
540 #Make all the paths to have the maximum number of segments in the set
541 #TODO: Refactor
542 def addMissingSegs(selPaths, byPart):
543 maxSegCntsByPart = []
544 maxSegCnt = 0
546 resSegCnt = []
547 sortedPaths = sorted(selPaths, key = lambda c: -len(c.parts))
549 for i, path in enumerate(sortedPaths):
550 if(byPart == False):
551 segCnt = path.getPartView().getSegCnt()
552 if(segCnt > maxSegCnt):
553 maxSegCnt = segCnt
554 else:
555 resSegCnt.append([])
556 for j, part in enumerate(path.parts):
557 partSegCnt = part.getSegCnt()
558 resSegCnt[i].append(partSegCnt)
560 #First path
561 if(j == len(maxSegCntsByPart)):
562 maxSegCntsByPart.append(partSegCnt)
564 #last part of this path, but other paths in set have more parts
565 elif((j == len(path.parts) - 1) and
566 len(maxSegCntsByPart) > len(path.parts)):
568 remainingSegs = sum(maxSegCntsByPart[j:])
569 if(partSegCnt <= remainingSegs):
570 resSegCnt[i][j] = remainingSegs
571 else:
572 #This part has more segs than the sum of the remaining part segs
573 #So distribute the extra count
574 distributeCnt(maxSegCntsByPart, j, (partSegCnt - remainingSegs))
576 #Also, adjust the seg count of the last part of the previous
577 #segments that had fewer than max number of parts
578 for k in range(0, i):
579 if(len(sortedPaths[k].parts) < len(maxSegCntsByPart)):
580 totalSegs = sum(maxSegCntsByPart)
581 existingSegs = sum(maxSegCntsByPart[:len(sortedPaths[k].parts)-1])
582 resSegCnt[k][-1] = totalSegs - existingSegs
584 elif(partSegCnt > maxSegCntsByPart[j]):
585 maxSegCntsByPart[j] = partSegCnt
586 for i, path in enumerate(sortedPaths):
588 if(byPart == False):
589 partView = path.getPartView()
590 segCnt = partView.getSegCnt()
591 diff = maxSegCnt - segCnt
593 if(diff > 0):
594 cnts = getSubdivCntPerSeg(partView, diff)
595 cumulSegIdx = 0
596 for j in range(0, len(path.parts)):
597 part = path.parts[j]
598 newSegs = []
599 for k, seg in enumerate(part.getSegs()):
600 numSubdivs = cnts[cumulSegIdx] + 1
601 newSegs += subdivideSeg(seg, numSubdivs)
602 cumulSegIdx += 1
604 path.parts[j] = Part(path, newSegs, part.isClosed)
605 else:
606 for j in range(0, len(path.parts)):
607 part = path.parts[j]
608 newSegs = []
610 partSegCnt = part.getSegCnt()
612 #TODO: Adding everything in the last part?
613 if(j == (len(path.parts)-1) and
614 len(maxSegCntsByPart) > len(path.parts)):
615 diff = resSegCnt[i][j] - partSegCnt
616 else:
617 diff = maxSegCntsByPart[j] - partSegCnt
619 if(diff > 0):
620 cnts = getSubdivCntPerSeg(part, diff)
622 for k, seg in enumerate(part.getSegs()):
623 seg = part.getSeg(k)
624 subdivCnt = cnts[k] + 1 #1 for the existing one
625 newSegs += subdivideSeg(seg, subdivCnt)
627 #isClosed won't be used, but let's update anyway
628 path.parts[j] = Part(path, newSegs, part.isClosed)
630 #TODO: Simplify (Not very readable)
631 def alignPath(path, matchParts, matchCriteria, alignBy, alignValues):
633 parts = path.parts[:]
635 if(matchParts == 'custom'):
636 fnMap = {'vCnt' : lambda part: -1 * part.getSegCnt(), \
637 'bbArea': lambda part: -1 * part.bboxSurfaceArea(worldSpace = True), \
638 'bbHeight' : lambda part: -1 * part.getBBHeight(worldSpace = True), \
639 'bbWidth' : lambda part: -1 * part.getBBWidth(worldSpace = True), \
640 'bbDepth' : lambda part: -1 * part.getBBDepth(worldSpace = True)
642 matchPartCmpFns = []
643 for criterion in matchCriteria:
644 fn = fnMap.get(criterion)
645 if(fn == None):
646 minmax = criterion[:3] == 'max' #0 if min; 1 if max
647 axisIdx = ord(criterion[3:]) - ord('X')
649 fn = eval('lambda part: part.getBBox(worldSpace = True)[' + \
650 str(minmax) + '][' + str(axisIdx) + ']')
652 matchPartCmpFns.append(fn)
654 def comparer(left, right):
655 for fn in matchPartCmpFns:
656 a = fn(left)
657 b = fn(right)
659 if(floatCmpWithMargin(a, b)):
660 continue
661 else:
662 return (a > b) - ( a < b) #No cmp in python3
664 return 0
666 parts = sorted(parts, key = cmp_to_key(comparer))
668 alignCmpFn = None
669 if(alignBy == 'vertCo'):
670 def evalCmp(criteria, pt1, pt2):
671 if(len(criteria) == 0):
672 return True
674 minmax = criteria[0][0]
675 axisIdx = criteria[0][1]
676 val1 = pt1[axisIdx]
677 val2 = pt2[axisIdx]
679 if(floatCmpWithMargin(val1, val2)):
680 criteria = criteria[:]
681 criteria.pop(0)
682 return evalCmp(criteria, pt1, pt2)
684 return val1 < val2 if minmax == 'min' else val1 > val2
686 alignCri = [[a[:3], ord(a[3:]) - ord('X')] for a in alignValues]
687 alignCmpFn = lambda pt1, pt2, curve: (evalCmp(alignCri, \
688 curve.matrix_world @ pt1, curve.matrix_world @ pt2))
690 startPt = None
691 startIdx = None
693 for i in range(0, len(parts)):
694 #Only truly closed parts
695 if(alignCmpFn != None and parts[i].isClosed):
696 for j in range(0, parts[i].getSegCnt()):
697 seg = parts[i].getSeg(j)
698 if(j == 0 or alignCmpFn(seg.start, startPt, path.curve)):
699 startPt = seg.start
700 startIdx = j
702 path.parts[i]= Part(path, parts[i].getSegsCopy(startIdx, None) + \
703 parts[i].getSegsCopy(None, startIdx), parts[i].isClosed)
704 else:
705 path.parts[i] = parts[i]
707 #TODO: Other shape key attributes like interpolation...?
708 def getExistingShapeKeyPaths(path):
709 obj = path.curve
710 paths = []
712 if(obj.data.shape_keys != None):
713 keyblocks = obj.data.shape_keys.key_blocks[:]
714 for key in keyblocks:
715 datacopy = obj.data.copy()
716 i = 0
717 for spline in datacopy.splines:
718 for pt in spline.bezier_points:
719 pt.co = key.data[i].co
720 pt.handle_left = key.data[i].handle_left
721 pt.handle_right = key.data[i].handle_right
722 i += 1
723 paths.append(Path(obj, datacopy, key.name))
724 return paths
726 def addShapeKeys(curve, paths, space):
727 for path in paths:
728 key = curve.shape_key_add(name = path.name)
729 pts = [pt for pset in path.getBezierPtsBySpline() for pt in pset]
730 for i, pt in enumerate(pts):
731 if(space == 'worldspace'):
732 pt = [curve.matrix_world.inverted() @ (path.curve.matrix_world @ p) for p in pt]
733 key.data[i].co = pt[0]
734 key.data[i].handle_left = pt[1]
735 key.data[i].handle_right = pt[2]
737 #TODO: Remove try
738 def safeRemoveObj(obj):
739 try:
740 collections = obj.users_collection
742 for c in collections:
743 c.objects.unlink(obj)
745 if(obj.name in bpy.context.scene.collection.objects):
746 bpy.context.scene.collection.objects.unlink(obj)
748 if(obj.data.users == 1):
749 if(obj.type == 'CURVE'):
750 bpy.data.curves.remove(obj.data) #This also removes object?
751 elif(obj.type == 'MESH'):
752 bpy.data.meshes.remove(obj.data)
754 bpy.data.objects.remove(obj)
755 except:
756 pass
759 def markVertHandler(self, context):
760 if(self.markVertex):
761 bpy.ops.wm.mark_vertex()
764 #################### UI and Registration ####################
766 class AssignShapeKeysOp(Operator):
767 bl_idname = "object.assign_shape_keys"
768 bl_label = "Assign Shape Keys"
769 bl_options = {'REGISTER', 'UNDO'}
771 def execute(self, context):
772 params = context.window_manager.AssignShapeKeyParams
773 removeOriginal = params.removeOriginal
774 space = params.space
776 matchParts = params.matchParts
777 matchCri1 = params.matchCri1
778 matchCri2 = params.matchCri2
779 matchCri3 = params.matchCri3
781 alignBy = params.alignCos
782 alignVal1 = params.alignVal1
783 alignVal2 = params.alignVal2
784 alignVal3 = params.alignVal3
786 targetObj = bpy.context.active_object
787 shapekeyObjs = [obj for obj in bpy.context.selected_objects if isBezier(obj) \
788 and obj != targetObj]
790 if(targetObj != None and isBezier(targetObj) and len(shapekeyObjs) > 0):
791 main(targetObj, shapekeyObjs, removeOriginal, space, \
792 matchParts, [matchCri1, matchCri2, matchCri3], \
793 alignBy, [alignVal1, alignVal2, alignVal3])
795 return {'FINISHED'}
798 class MarkerController:
799 drawHandlerRef = None
800 defPointSize = 6
801 ptColor = (0, .8, .8, 1)
803 def createSMMap(self, context):
804 objs = context.selected_objects
805 smMap = {}
806 for curve in objs:
807 if(not isBezier(curve)):
808 continue
810 smMap[curve.name] = {}
811 mw = curve.matrix_world
812 for splineIdx, spline in enumerate(curve.data.splines):
813 if(not spline.use_cyclic_u):
814 continue
816 #initialize to the curr start vert co and idx
817 smMap[curve.name][splineIdx] = \
818 [mw @ curve.data.splines[splineIdx].bezier_points[0].co, 0]
820 for pt in spline.bezier_points:
821 pt.select_control_point = False
823 if(len(smMap[curve.name]) == 0):
824 del smMap[curve.name]
826 return smMap
828 def createBatch(self, context):
829 positions = [s[0] for cn in self.smMap.values() for s in cn.values()]
830 colors = [MarkerController.ptColor for i in range(0, len(positions))]
832 self.batch = batch_for_shader(self.shader, \
833 "POINTS", {"pos": positions, "color": colors})
835 if context.area:
836 context.area.tag_redraw()
838 def drawHandler(self):
839 gpu.state.point_size_set(MarkerController.defPointSize)
840 self.batch.draw(self.shader)
842 def removeMarkers(self, context):
843 if(MarkerController.drawHandlerRef != None):
844 bpy.types.SpaceView3D.draw_handler_remove(MarkerController.drawHandlerRef, \
845 "WINDOW")
847 if(context.area and hasattr(context.space_data, 'region_3d')):
848 context.area.tag_redraw()
850 MarkerController.drawHandlerRef = None
852 self.deselectAll()
854 def __init__(self, context):
855 self.smMap = self.createSMMap(context)
856 self.shader = gpu.shader.from_builtin('FLAT_COLOR')
857 # self.shader.bind()
859 MarkerController.drawHandlerRef = \
860 bpy.types.SpaceView3D.draw_handler_add(self.drawHandler, \
861 (), "WINDOW", "POST_VIEW")
863 self.createBatch(context)
865 def saveStartVerts(self):
866 for curveName in self.smMap.keys():
867 curve = bpy.data.objects[curveName]
868 splines = curve.data.splines
869 spMap = self.smMap[curveName]
871 for splineIdx in spMap.keys():
872 markerInfo = spMap[splineIdx]
873 if(markerInfo[1] != 0):
874 pts = splines[splineIdx].bezier_points
875 loc, idx = markerInfo[0], markerInfo[1]
876 cnt = len(pts)
878 ptCopy = [[p.co.copy(), p.handle_right.copy(), \
879 p.handle_left.copy(), p.handle_right_type, \
880 p.handle_left_type] for p in pts]
882 for i, pt in enumerate(pts):
883 srcIdx = (idx + i) % cnt
884 p = ptCopy[srcIdx]
886 #Must set the types first
887 pt.handle_right_type = p[3]
888 pt.handle_left_type = p[4]
889 pt.co = p[0]
890 pt.handle_right = p[1]
891 pt.handle_left = p[2]
893 def updateSMMap(self):
894 for curveName in self.smMap.keys():
895 curve = bpy.data.objects[curveName]
896 spMap = self.smMap[curveName]
897 mw = curve.matrix_world
899 for splineIdx in spMap.keys():
900 markerInfo = spMap[splineIdx]
901 loc, idx = markerInfo[0], markerInfo[1]
902 pts = curve.data.splines[splineIdx].bezier_points
904 selIdxs = [x for x in range(0, len(pts)) \
905 if pts[x].select_control_point == True]
907 selIdx = selIdxs[0] if(len(selIdxs) > 0 ) else idx
908 co = mw @ pts[selIdx].co
909 self.smMap[curveName][splineIdx] = [co, selIdx]
911 def deselectAll(self):
912 for curveName in self.smMap.keys():
913 curve = bpy.data.objects[curveName]
914 for spline in curve.data.splines:
915 for pt in spline.bezier_points:
916 pt.select_control_point = False
918 def getSpaces3D(context):
919 areas3d = [area for area in context.window.screen.areas \
920 if area.type == 'VIEW_3D']
922 return [s for a in areas3d for s in a.spaces if s.type == 'VIEW_3D']
924 def hideHandles(context):
925 states = []
926 spaces = MarkerController.getSpaces3D(context)
927 for s in spaces:
928 if(hasattr(s.overlay, 'show_curve_handles')):
929 states.append(s.overlay.show_curve_handles)
930 s.overlay.show_curve_handles = False
931 elif(hasattr(s.overlay, 'display_handle')): # 2.90
932 states.append(s.overlay.display_handle)
933 s.overlay.display_handle = 'NONE'
934 return states
936 def resetShowHandleState(context, handleStates):
937 spaces = MarkerController.getSpaces3D(context)
938 for i, s in enumerate(spaces):
939 if(hasattr(s.overlay, 'show_curve_handles')):
940 s.overlay.show_curve_handles = handleStates[i]
941 elif(hasattr(s.overlay, 'display_handle')): # 2.90
942 s.overlay.display_handle = handleStates[i]
945 class ModalMarkSegStartOp(Operator):
946 bl_description = "Mark Vertex"
947 bl_idname = "wm.mark_vertex"
948 bl_label = "Mark Start Vertex"
950 def cleanup(self, context):
951 wm = context.window_manager
952 wm.event_timer_remove(self._timer)
953 self.markerState.removeMarkers(context)
954 MarkerController.resetShowHandleState(context, self.handleStates)
955 context.window_manager.AssignShapeKeyParams.markVertex = False
957 def modal (self, context, event):
958 params = context.window_manager.AssignShapeKeyParams
960 if(context.mode == 'OBJECT' or event.type == "ESC" or\
961 not context.window_manager.AssignShapeKeyParams.markVertex):
962 self.cleanup(context)
963 return {'CANCELLED'}
965 elif(event.type == "RET"):
966 self.markerState.saveStartVerts()
967 self.cleanup(context)
968 return {'FINISHED'}
970 if(event.type == 'TIMER'):
971 self.markerState.updateSMMap()
972 self.markerState.createBatch(context)
974 return {"PASS_THROUGH"}
976 def execute(self, context):
977 #TODO: Why such small step?
978 self._timer = context.window_manager.event_timer_add(time_step = 0.0001, \
979 window = context.window)
981 context.window_manager.modal_handler_add(self)
982 self.markerState = MarkerController(context)
984 #Hide so that users don't accidentally select handles instead of points
985 self.handleStates = MarkerController.hideHandles(context)
987 return {"RUNNING_MODAL"}
990 class AssignShapeKeyParams(bpy.types.PropertyGroup):
992 removeOriginal : BoolProperty(name = "Remove Shape Key Objects", \
993 description = "Remove shape key objects after assigning to target", \
994 default = True)
996 space : EnumProperty(name = "Space", \
997 items = [('worldspace', 'World Space', 'worldspace'),
998 ('localspace', 'Local Space', 'localspace')], \
999 description = 'Space that shape keys are evluated in')
1001 alignCos : EnumProperty(name="Vertex Alignment", items = \
1002 [("-None-", 'Manual Alignment', "Align curve segments based on starting vertex"), \
1003 ('vertCo', 'Vertex Coordinates', 'Align curve segments based on vertex coordinates')], \
1004 description = 'Start aligning the vertices of target and shape keys from',
1005 default = '-None-')
1007 alignVal1 : EnumProperty(name="Value 1",
1008 items = alignList, default = 'minX', description='First align criterion')
1010 alignVal2 : EnumProperty(name="Value 2",
1011 items = alignList, default = 'maxY', description='Second align criterion')
1013 alignVal3 : EnumProperty(name="Value 3",
1014 items = alignList, default = 'minZ', description='Third align criterion')
1016 matchParts : EnumProperty(name="Match Parts", items = \
1017 [("-None-", 'None', "Don't match parts"), \
1018 ('default', 'Default', 'Use part (spline) order as in curve'), \
1019 ('custom', 'Custom', 'Use one of the custom criteria for part matching')], \
1020 description='Match disconnected parts', default = 'default')
1022 matchCri1 : EnumProperty(name="Value 1",
1023 items = matchList, default = 'minX', description='First match criterion')
1025 matchCri2 : EnumProperty(name="Value 2",
1026 items = matchList, default = 'maxY', description='Second match criterion')
1028 matchCri3 : EnumProperty(name="Value 3",
1029 items = matchList, default = 'minZ', description='Third match criterion')
1031 markVertex : BoolProperty(name="Mark Starting Vertices", \
1032 description='Mark first vertices in all splines of selected curves', \
1033 default = False, update = markVertHandler)
1036 class AssignShapeKeysPanel(Panel):
1038 bl_label = "Curve Shape Keys"
1039 bl_idname = "CURVE_PT_assign_shape_keys"
1040 bl_space_type = 'VIEW_3D'
1041 bl_region_type = 'UI'
1042 bl_category = "Edit"
1043 bl_options = {'DEFAULT_CLOSED'}
1045 @classmethod
1046 def poll(cls, context):
1047 return context.mode in {'OBJECT', 'EDIT_CURVE'}
1049 def draw(self, context):
1051 layout = self.layout
1052 layout.label(text='Morph Curves:')
1053 col = layout.column()
1054 params = context.window_manager.AssignShapeKeyParams
1056 if(context.mode == 'OBJECT'):
1057 row = col.row()
1058 row.prop(params, "removeOriginal")
1060 row = col.row()
1061 row.prop(params, "space")
1063 row = col.row()
1064 row.prop(params, "alignCos")
1066 if(params.alignCos == 'vertCo'):
1067 row = col.row()
1068 row.prop(params, "alignVal1")
1069 row.prop(params, "alignVal2")
1070 row.prop(params, "alignVal3")
1072 row = col.row()
1073 row.prop(params, "matchParts")
1075 if(params.matchParts == 'custom'):
1076 row = col.row()
1077 row.prop(params, "matchCri1")
1078 row.prop(params, "matchCri2")
1079 row.prop(params, "matchCri3")
1081 row = col.row()
1082 row.operator("object.assign_shape_keys")
1083 else:
1084 col.prop(params, "markVertex", \
1085 toggle = True)
1088 def updatePanel(self, context):
1089 try:
1090 panel = AssignShapeKeysPanel
1091 if "bl_rna" in panel.__dict__:
1092 bpy.utils.unregister_class(panel)
1094 panel.bl_category = context.preferences.addons[__name__].preferences.category
1095 bpy.utils.register_class(panel)
1097 except Exception as e:
1098 print("Assign Shape Keys: Updating Panel locations has failed", e)
1100 class AssignShapeKeysPreferences(AddonPreferences):
1101 bl_idname = __name__
1103 category: StringProperty(
1104 name = "Tab Category",
1105 description = "Choose a name for the category of the panel",
1106 default = "Edit",
1107 update = updatePanel
1110 def draw(self, context):
1111 layout = self.layout
1112 row = layout.row()
1113 col = row.column()
1114 col.label(text="Tab Category:")
1115 col.prop(self, "category", text="")
1117 # registering and menu integration
1118 def register():
1119 bpy.utils.register_class(AssignShapeKeysPanel)
1120 bpy.utils.register_class(AssignShapeKeysOp)
1121 bpy.utils.register_class(AssignShapeKeyParams)
1122 bpy.types.WindowManager.AssignShapeKeyParams = \
1123 bpy.props.PointerProperty(type=AssignShapeKeyParams)
1124 bpy.utils.register_class(ModalMarkSegStartOp)
1125 bpy.utils.register_class(AssignShapeKeysPreferences)
1126 updatePanel(None, bpy.context)
1128 def unregister():
1129 bpy.utils.unregister_class(AssignShapeKeysOp)
1130 bpy.utils.unregister_class(AssignShapeKeysPanel)
1131 del bpy.types.WindowManager.AssignShapeKeyParams
1132 bpy.utils.unregister_class(AssignShapeKeyParams)
1133 bpy.utils.unregister_class(ModalMarkSegStartOp)
1134 bpy.utils.unregister_class(AssignShapeKeysPreferences)
1136 if __name__ == "__main__":
1137 register()