3 # This Blender add-on assigns one or more Bezier Curves as shape keys to another
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
24 "name": "Assign Shape Keys",
25 "author": "Shrinivas Kulkarni",
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
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
)))
70 #pts[0] - start, pts[1] - ctrl1, pts[2] - ctrl2, , pts[3] - end
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
):
78 mid
= Segment
.pointAtT(pts
, t1_5
)
79 l
= (end
- start
).length
80 l2
= (mid
- start
).length
+ (end
- mid
).length
82 return (Segment
.getSegLenRecurs(pts
, start
, mid
, t1
, t1_5
, error
) +
83 Segment
.getSegLenRecurs(pts
, mid
, end
, t1_5
, t2
, error
))
86 def __init__(self
, start
, ctrl1
, ctrl2
, 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
]
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
)
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
) + \
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)]
155 for i
in range(0, 3):
159 solns
.append(0)#Independent of t so lets take the starting pt
161 solns
.append(c
[i
] / b
[i
])
163 rootFact
= b
[i
] * b
[i
] - 4 * a
[i
] * c
[i
]
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
):
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
183 def __init__(self
, parent
, segs
, isClosed
):
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
)
195 self
.bboxWorldSpace
= None
197 def getSeg(self
, idx
):
198 return self
.segs
[idx
]
203 def getSegsCopy(self
, start
, end
):
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):
215 if(worldSpace
and self
.bboxWorldSpace
!= None):
216 return self
.bboxWorldSpace
218 leftBotBack_rgtTopFront
= [[None]*3,[None]*3]
220 for seg
in self
.segs
:
223 bb
= seg
.bbox(self
.parent
.curve
.matrix_world
)
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
]
238 self
.bboxWorldSpace
= leftBotBack_rgtTopFront
240 self
.bbox
= leftBotBack_rgtTopFront
242 return leftBotBack_rgtTopFront
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
])
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
)
269 return len(self
.segs
)
271 def getBezierPtsInfo(self
):
275 for j
, seg
in enumerate(self
.getSegs()):
278 handleRight
= seg
.ctrl1
282 handleLeft
= self
.getSeg(-1).ctrl2
286 handleLeft
= prevSeg
.ctrl2
288 bezierPtsInfo
.append([pt
, handleLeft
, handleRight
])
291 if(self
.toClose
== True):
292 bezierPtsInfo
[-1][2] = seg
.ctrl1
294 bezierPtsInfo
.append([prevSeg
.end
, prevSeg
.ctrl2
, prevSeg
.end
])
299 return str(self
.length
)
303 def __init__(self
, curve
, objData
= None, name
= None):
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)
323 def getPartBoundaryIdxs(self
):
328 cumulCnt
+= p
.getSegCnt()
329 cumulCntList
.add(cumulCnt
)
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]
340 for i
in range(0, len(segCntsPerPart
)):
344 currIdx
= segCntsPerPart
[i
-1]
346 nextIdx
= segCntsPerPart
[i
]
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]):
360 if(partIdx
< len(oldParts
)):
361 currPart
= oldParts
[partIdx
]
363 def getBezierPtsBySpline(self
):
366 for i
, part
in enumerate(self
.parts
):
367 data
.append(part
.getBezierPtsInfo())
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'
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
)
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
415 alignPath(path
, matchParts
, matchCriteria
, alignBy
, alignValues
)
417 addMissingSegs(userSel
, byPart
= (matchParts
!= "-None-"))
421 bIdxs
= bIdxs
.union(path
.getPartBoundaryIdxs())
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
432 for j
, part
in enumerate(path
.parts
):
433 part
.toClose
= allToClose
[j
]
437 if(len(existingKeys
) == 0):
438 target
.curve
.shape_key_add(name
= 'Basis')
440 addShapeKeys(target
.curve
, shapekeys
, space
)
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
))
455 def subdivideSeg(origSeg
, noSegs
):
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
)
469 seg
= origSeg
.partialSeg(oldT
, 1)
475 def getSubdivCntPerSeg(part
, toAddCnt
):
478 def __init__(self
, idx
, seg
):
481 self
.length
= seg
.length
484 def __init__(self
, part
):
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
504 for i
in range(0, segToDivideCnt
):
505 segLen
= segsToDivide
[i
].seg
.length
507 divideCnt
= int(round(segLen
/avgLen
)) - 1
511 if((addedCnt
+ divideCnt
) >= toAddCnt
):
512 cnts
[segsToDivide
[i
].idx
] = toAddCnt
- addedCnt
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
525 if(toAddCnt
== addedCnt
):
530 #Just distribute equally; this is likely a rare condition. So why complicate?
531 def distributeCnt(maxSegCntsByPart
, startIdx
, extraCnt
):
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
544 def addMissingSegs(selPaths
, byPart
):
545 maxSegCntsByPart
= []
549 sortedPaths
= sorted(selPaths
, key
= lambda c
: -len(c
.parts
))
551 for i
, path
in enumerate(sortedPaths
):
553 segCnt
= path
.getPartView().getSegCnt()
554 if(segCnt
> maxSegCnt
):
558 for j
, part
in enumerate(path
.parts
):
559 partSegCnt
= part
.getSegCnt()
560 resSegCnt
[i
].append(partSegCnt
)
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
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
):
591 partView
= path
.getPartView()
592 segCnt
= partView
.getSegCnt()
593 diff
= maxSegCnt
- segCnt
596 cnts
= getSubdivCntPerSeg(partView
, diff
)
598 for j
in range(0, len(path
.parts
)):
601 for k
, seg
in enumerate(part
.getSegs()):
602 numSubdivs
= cnts
[cumulSegIdx
] + 1
603 newSegs
+= subdivideSeg(seg
, numSubdivs
)
606 path
.parts
[j
] = Part(path
, newSegs
, part
.isClosed
)
608 for j
in range(0, len(path
.parts
)):
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
619 diff
= maxSegCntsByPart
[j
] - partSegCnt
622 cnts
= getSubdivCntPerSeg(part
, diff
)
624 for k
, seg
in enumerate(part
.getSegs()):
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)
645 for criterion
in matchCriteria
:
646 fn
= fnMap
.get(criterion
)
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
:
661 if(floatCmpWithMargin(a
, b
)):
664 return (a
> b
) - ( a
< b
) #No cmp in python3
668 parts
= sorted(parts
, key
= cmp_to_key(comparer
))
671 if(alignBy
== 'vertCo'):
672 def evalCmp(criteria
, pt1
, pt2
):
673 if(len(criteria
) == 0):
676 minmax
= criteria
[0][0]
677 axisIdx
= criteria
[0][1]
681 if(floatCmpWithMargin(val1
, val2
)):
682 criteria
= criteria
[:]
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
))
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
)):
704 path
.parts
[i
]= Part(path
, parts
[i
].getSegsCopy(startIdx
, None) + \
705 parts
[i
].getSegsCopy(None, startIdx
), parts
[i
].isClosed
)
707 path
.parts
[i
] = parts
[i
]
709 #TODO: Other shape key attributes like interpolation...?
710 def getExistingShapeKeyPaths(path
):
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()
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
725 paths
.append(Path(obj
, datacopy
, key
.name
))
728 def addShapeKeys(curve
, paths
, space
):
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]
740 def safeRemoveObj(obj
):
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
)
761 def markVertHandler(self
, context
):
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
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
])
800 class MarkerController
:
801 drawHandlerRef
= None
803 ptColor
= (0, .8, .8, 1)
805 def createSMMap(self
, context
):
806 objs
= context
.selected_objects
809 if(not isBezier(curve
)):
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
):
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
]
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
})
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
, \
849 if(context
.area
and hasattr(context
.space_data
, 'region_3d')):
850 context
.area
.tag_redraw()
852 MarkerController
.drawHandlerRef
= None
856 def __init__(self
, context
):
857 self
.smMap
= self
.createSMMap(context
)
858 self
.shader
= gpu
.shader
.from_builtin('3D_FLAT_COLOR')
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]
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
888 #Must set the types first
889 pt
.handle_right_type
= p
[3]
890 pt
.handle_left_type
= p
[4]
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
):
928 spaces
= MarkerController
.getSpaces3D(context
)
930 states
.append(s
.overlay
.show_curve_handles
)
931 s
.overlay
.show_curve_handles
= False
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
)
960 elif(event
.type == "RET"):
961 self
.markerState
.saveStartVerts()
962 self
.cleanup(context
)
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
)
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", \
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',
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'}
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'):
1066 row
.prop(params
, "removeOriginal")
1069 row
.prop(params
, "space")
1072 row
.prop(params
, "alignCos")
1074 if(params
.alignCos
== 'vertCo'):
1076 row
.prop(params
, "alignVal1")
1077 row
.prop(params
, "alignVal2")
1078 row
.prop(params
, "alignVal3")
1081 row
.prop(params
, "matchParts")
1083 if(params
.matchParts
== 'custom'):
1085 row
.prop(params
, "matchCri1")
1086 row
.prop(params
, "matchCri2")
1087 row
.prop(params
, "matchCri3")
1090 row
.operator("object.assign_shape_keys")
1092 col
.prop(params
, "markVertex", \
1096 def updatePanel(self
, context
):
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",
1115 update
= updatePanel
1118 def draw(self
, context
):
1119 layout
= self
.layout
1122 col
.label(text
="Tab Category:")
1123 col
.prop(self
, "category", text
="")
1125 # registering and menu integration
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
)
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__":