1 # SPDX-License-Identifier: GPL-3.0-or-later
2 # Copyright (C) 2019 Shrinivas Kulkarni
4 # This Blender add-on assigns one or more Bezier Curves as shape keys to another
7 # Supported Blender Versions: 2.8x
9 # https://github.com/Shriinivas/assignshapekey/blob/master/LICENSE
11 import bpy
, bmesh
, gpu
12 from gpu_extras
.batch
import batch_for_shader
13 from bpy
.props
import BoolProperty
, EnumProperty
, StringProperty
14 from collections
import OrderedDict
15 from mathutils
import Vector
16 from math
import sqrt
, floor
17 from functools
import cmp_to_key
18 from bpy
.types
import Panel
, Operator
, AddonPreferences
22 "name": "Assign Shape Keys",
23 "author": "Shrinivas Kulkarni",
26 "location": "View 3D > Sidebar > Edit Tab",
27 "description": "Assigns one or more Bezier curves as shape keys to another Bezier curve",
28 "doc_url": "{BLENDER_MANUAL_URL}/addons/add_curve/assign_shape_keys.html",
29 "category": "Add Curve",
32 alignList
= [('minX', 'Min X', 'Align vertices with Min X'),
33 ('maxX', 'Max X', 'Align vertices with Max X'),
34 ('minY', 'Min Y', 'Align vertices with Min Y'),
35 ('maxY', 'Max Y', 'Align vertices with Max Y'),
36 ('minZ', 'Min Z', 'Align vertices with Min Z'),
37 ('maxZ', 'Max Z', 'Align vertices with Max Z')]
39 matchList
= [('vCnt', 'Vertex Count', 'Match by vertex count'),
40 ('bbArea', 'Area', 'Match by surface area of the bounding box'), \
41 ('bbHeight', 'Height', 'Match by bounding box height'), \
42 ('bbWidth', 'Width', 'Match by bounding box width'),
43 ('bbDepth', 'Depth', 'Match by bounding box depth'),
44 ('minX', 'Min X', 'Match by bounding box Min X'),
45 ('maxX', 'Max X', 'Match by bounding box Max X'),
46 ('minY', 'Min Y', 'Match by bounding box Min Y'),
47 ('maxY', 'Max Y', 'Match by bounding box Max Y'),
48 ('minZ', 'Min Z', 'Match by bounding box Min Z'),
49 ('maxZ', 'Max Z', 'Match by bounding box Max Z')]
51 DEF_ERR_MARGIN
= 0.0001
54 return obj
.type == 'CURVE' and len(obj
.data
.splines
) > 0 \
55 and obj
.data
.splines
[0].type == 'BEZIER'
57 #Avoid errors due to floating point conversions/comparisons
58 #TODO: return -1, 0, 1
59 def floatCmpWithMargin(float1
, float2
, margin
= DEF_ERR_MARGIN
):
60 return abs(float1
- float2
) < margin
62 def vectCmpWithMargin(v1
, v2
, margin
= DEF_ERR_MARGIN
):
63 return all(floatCmpWithMargin(v1
[i
], v2
[i
], margin
) for i
in range(0, len(v1
)))
67 #pts[0] - start, pts[1] - ctrl1, pts[2] - ctrl2, , pts[3] - end
69 return pts
[0] + t
* (3 * (pts
[1] - pts
[0]) +
70 t
* (3 * (pts
[0] + pts
[2]) - 6 * pts
[1] +
71 t
* (-pts
[0] + 3 * (pts
[1] - pts
[2]) + pts
[3])))
73 def getSegLenRecurs(pts
, start
, end
, t1
= 0, t2
= 1, error
= DEF_ERR_MARGIN
):
75 mid
= Segment
.pointAtT(pts
, t1_5
)
76 l
= (end
- start
).length
77 l2
= (mid
- start
).length
+ (end
- mid
).length
79 return (Segment
.getSegLenRecurs(pts
, start
, mid
, t1
, t1_5
, error
) +
80 Segment
.getSegLenRecurs(pts
, mid
, end
, t1_5
, t2
, error
))
83 def __init__(self
, start
, ctrl1
, ctrl2
, end
):
88 pts
= [start
, ctrl1
, ctrl2
, end
]
89 self
.length
= Segment
.getSegLenRecurs(pts
, start
, end
)
91 #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
92 def partialSeg(self
, t0
, t1
):
93 pts
= [self
.start
, self
.ctrl1
, self
.ctrl2
, self
.end
]
100 #Let's make at least the line segments of predictable length :)
101 if(pts
[0] == pts
[1] and pts
[2] == pts
[3]):
102 pt0
= Vector([(1 - t0
) * pts
[0][i
] + t0
* pts
[2][i
] for i
in range(0, 3)])
103 pt1
= Vector([(1 - t1
) * pts
[0][i
] + t1
* pts
[2][i
] for i
in range(0, 3)])
104 return Segment(pt0
, pt0
, pt1
, pt1
)
109 qa
= [pts
[0][i
]*u0
*u0
+ pts
[1][i
]*2*t0
*u0
+ pts
[2][i
]*t0
*t0
for i
in range(0, 3)]
110 qb
= [pts
[0][i
]*u1
*u1
+ pts
[1][i
]*2*t1
*u1
+ pts
[2][i
]*t1
*t1
for i
in range(0, 3)]
111 qc
= [pts
[1][i
]*u0
*u0
+ pts
[2][i
]*2*t0
*u0
+ pts
[3][i
]*t0
*t0
for i
in range(0, 3)]
112 qd
= [pts
[1][i
]*u1
*u1
+ pts
[2][i
]*2*t1
*u1
+ pts
[3][i
]*t1
*t1
for i
in range(0, 3)]
114 pta
= Vector([qa
[i
]*u0
+ qc
[i
]*t0
for i
in range(0, 3)])
115 ptb
= Vector([qa
[i
]*u1
+ qc
[i
]*t1
for i
in range(0, 3)])
116 ptc
= Vector([qb
[i
]*u0
+ qd
[i
]*t0
for i
in range(0, 3)])
117 ptd
= Vector([qb
[i
]*u1
+ qd
[i
]*t1
for i
in range(0, 3)])
119 return Segment(pta
, ptb
, ptc
, ptd
)
121 #see https://stackoverflow.com/questions/24809978/calculating-the-bounding-box-of-cubic-bezier-curve
122 #(3 D - 9 C + 9 B - 3 A) t^2 + (6 A - 12 B + 6 C) t + 3 (B - A)
123 #pts[0] - start, pts[1] - ctrl1, pts[2] - ctrl2, , pts[3] - end
124 #TODO: Return Vectors to make world space calculations consistent
125 def bbox(self
, mw
= None):
126 def evalBez(AA
, BB
, CC
, DD
, t
):
127 return AA
* (1 - t
) * (1 - t
) * (1 - t
) + \
128 3 * BB
* t
* (1 - t
) * (1 - t
) + \
129 3 * CC
* t
* t
* (1 - t
) + \
143 MINXYZ
= [min([A
[i
], D
[i
]]) for i
in range(0, 3)]
144 MAXXYZ
= [max([A
[i
], D
[i
]]) for i
in range(0, 3)]
145 leftBotBack_rgtTopFront
= [MINXYZ
, MAXXYZ
]
147 a
= [3 * D
[i
] - 9 * C
[i
] + 9 * B
[i
] - 3 * A
[i
] for i
in range(0, 3)]
148 b
= [6 * A
[i
] - 12 * B
[i
] + 6 * C
[i
] for i
in range(0, 3)]
149 c
= [3 * (B
[i
] - A
[i
]) for i
in range(0, 3)]
152 for i
in range(0, 3):
156 solns
.append(0)#Independent of t so lets take the starting pt
158 solns
.append(c
[i
] / b
[i
])
160 rootFact
= b
[i
] * b
[i
] - 4 * a
[i
] * c
[i
]
162 #Two solutions with + and - sqrt
163 solns
.append((-b
[i
] + sqrt(rootFact
)) / (2 * a
[i
]))
164 solns
.append((-b
[i
] - sqrt(rootFact
)) / (2 * a
[i
]))
165 solnsxyz
.append(solns
)
167 for i
, soln
in enumerate(solnsxyz
):
168 for j
, t
in enumerate(soln
):
170 co
= evalBez(A
[i
], B
[i
], C
[i
], D
[i
], t
)
171 if(co
< leftBotBack_rgtTopFront
[0][i
]):
172 leftBotBack_rgtTopFront
[0][i
] = co
173 if(co
> leftBotBack_rgtTopFront
[1][i
]):
174 leftBotBack_rgtTopFront
[1][i
] = co
176 return leftBotBack_rgtTopFront
180 def __init__(self
, parent
, segs
, isClosed
):
185 self
.isClosed
= isClosed
187 #Indicates if this should be closed based on its counterparts in other paths
188 self
.toClose
= isClosed
190 self
.length
= sum(seg
.length
for seg
in self
.segs
)
192 self
.bboxWorldSpace
= None
194 def getSeg(self
, idx
):
195 return self
.segs
[idx
]
200 def getSegsCopy(self
, start
, end
):
205 return self
.segs
[start
:end
]
207 def getBBox(self
, worldSpace
):
208 #Avoid frequent calculations, as this will be called in compare method
209 if(not worldSpace
and self
.bbox
!= None):
212 if(worldSpace
and self
.bboxWorldSpace
!= None):
213 return self
.bboxWorldSpace
215 leftBotBack_rgtTopFront
= [[None]*3,[None]*3]
217 for seg
in self
.segs
:
220 bb
= seg
.bbox(self
.parent
.curve
.matrix_world
)
224 for i
in range(0, 3):
225 if (leftBotBack_rgtTopFront
[0][i
] == None or \
226 bb
[0][i
] < leftBotBack_rgtTopFront
[0][i
]):
227 leftBotBack_rgtTopFront
[0][i
] = bb
[0][i
]
229 for i
in range(0, 3):
230 if (leftBotBack_rgtTopFront
[1][i
] == None or \
231 bb
[1][i
] > leftBotBack_rgtTopFront
[1][i
]):
232 leftBotBack_rgtTopFront
[1][i
] = bb
[1][i
]
235 self
.bboxWorldSpace
= leftBotBack_rgtTopFront
237 self
.bbox
= leftBotBack_rgtTopFront
239 return leftBotBack_rgtTopFront
242 def getBBDiff(self
, axisIdx
, worldSpace
):
243 obj
= self
.parent
.curve
244 bbox
= self
.getBBox(worldSpace
)
245 diff
= abs(bbox
[1][axisIdx
] - bbox
[0][axisIdx
])
248 def getBBWidth(self
, worldSpace
):
249 return self
.getBBDiff(0, worldSpace
)
251 def getBBHeight(self
, worldSpace
):
252 return self
.getBBDiff(1, worldSpace
)
254 def getBBDepth(self
, worldSpace
):
255 return self
.getBBDiff(2, worldSpace
)
257 def bboxSurfaceArea(self
, worldSpace
):
258 leftBotBack_rgtTopFront
= self
.getBBox(worldSpace
)
259 w
= abs( leftBotBack_rgtTopFront
[1][0] - leftBotBack_rgtTopFront
[0][0] )
260 l
= abs( leftBotBack_rgtTopFront
[1][1] - leftBotBack_rgtTopFront
[0][1] )
261 d
= abs( leftBotBack_rgtTopFront
[1][2] - leftBotBack_rgtTopFront
[0][2] )
263 return 2 * (w
* l
+ w
* d
+ l
* d
)
266 return len(self
.segs
)
268 def getBezierPtsInfo(self
):
272 for j
, seg
in enumerate(self
.getSegs()):
275 handleRight
= seg
.ctrl1
279 handleLeft
= self
.getSeg(-1).ctrl2
283 handleLeft
= prevSeg
.ctrl2
285 bezierPtsInfo
.append([pt
, handleLeft
, handleRight
])
288 if(self
.toClose
== True):
289 bezierPtsInfo
[-1][2] = seg
.ctrl1
291 bezierPtsInfo
.append([prevSeg
.end
, prevSeg
.ctrl2
, prevSeg
.end
])
296 return str(self
.length
)
300 def __init__(self
, curve
, objData
= None, name
= None):
311 self
.parts
= [Part(self
, getSplineSegs(s
), s
.use_cyclic_u
) for s
in objData
.splines
]
313 def getPartCnt(self
):
314 return len(self
.parts
)
316 def getPartView(self
):
317 p
= Part(self
, [seg
for part
in self
.parts
for seg
in part
.getSegs()], None)
320 def getPartBoundaryIdxs(self
):
325 cumulCnt
+= p
.getSegCnt()
326 cumulCntList
.add(cumulCnt
)
330 def updatePartsList(self
, segCntsPerPart
, byPart
):
331 monolithicSegList
= [seg
for part
in self
.parts
for seg
in part
.getSegs()]
332 oldParts
= self
.parts
[:]
333 currPart
= oldParts
[0]
337 for i
in range(0, len(segCntsPerPart
)):
341 currIdx
= segCntsPerPart
[i
-1]
343 nextIdx
= segCntsPerPart
[i
]
346 if(vectCmpWithMargin(monolithicSegList
[currIdx
].start
, \
347 currPart
.getSegs()[0].start
) and \
348 vectCmpWithMargin(monolithicSegList
[nextIdx
-1].end
, \
349 currPart
.getSegs()[-1].end
)):
350 isClosed
= currPart
.isClosed
352 self
.parts
.append(Part(self
, \
353 monolithicSegList
[currIdx
:nextIdx
], isClosed
))
355 if(monolithicSegList
[nextIdx
-1] == currPart
.getSegs()[-1]):
357 if(partIdx
< len(oldParts
)):
358 currPart
= oldParts
[partIdx
]
360 def getBezierPtsBySpline(self
):
363 for i
, part
in enumerate(self
.parts
):
364 data
.append(part
.getBezierPtsInfo())
368 def getNewCurveData(self
):
370 newCurveData
= self
.curve
.data
.copy()
371 newCurveData
.splines
.clear()
373 splinesData
= self
.getBezierPtsBySpline()
375 for i
, newPoints
in enumerate(splinesData
):
377 spline
= newCurveData
.splines
.new('BEZIER')
378 spline
.bezier_points
.add(len(newPoints
)-1)
379 spline
.use_cyclic_u
= self
.parts
[i
].toClose
381 for j
in range(0, len(spline
.bezier_points
)):
382 newPoint
= newPoints
[j
]
383 spline
.bezier_points
[j
].co
= newPoint
[0]
384 spline
.bezier_points
[j
].handle_left
= newPoint
[1]
385 spline
.bezier_points
[j
].handle_right
= newPoint
[2]
386 spline
.bezier_points
[j
].handle_right_type
= 'FREE'
390 def updateCurve(self
):
391 curveData
= self
.curve
.data
392 #Remove existing shape keys first
393 if(curveData
.shape_keys
!= None):
394 keyblocks
= reversed(curveData
.shape_keys
.key_blocks
)
396 self
.curve
.shape_key_remove(sk
)
397 self
.curve
.data
= self
.getNewCurveData()
398 bpy
.data
.curves
.remove(curveData
)
400 def main(targetObj
, shapekeyObjs
, removeOriginal
, space
, matchParts
, \
401 matchCriteria
, alignBy
, alignValues
):
403 target
= Path(targetObj
)
405 shapekeys
= [Path(c
) for c
in shapekeyObjs
]
407 existingKeys
= getExistingShapeKeyPaths(target
)
408 shapekeys
= existingKeys
+ shapekeys
409 userSel
= [target
] + shapekeys
412 alignPath(path
, matchParts
, matchCriteria
, alignBy
, alignValues
)
414 addMissingSegs(userSel
, byPart
= (matchParts
!= "-None-"))
418 bIdxs
= bIdxs
.union(path
.getPartBoundaryIdxs())
421 path
.updatePartsList(sorted(list(bIdxs
)), byPart
= False)
423 #All will have the same part count by now
424 allToClose
= [all(path
.parts
[j
].isClosed
for path
in userSel
)
425 for j
in range(0, len(userSel
[0].parts
))]
427 #All paths will have the same no of splines with the same no of bezier points
429 for j
, part
in enumerate(path
.parts
):
430 part
.toClose
= allToClose
[j
]
434 if(len(existingKeys
) == 0):
435 target
.curve
.shape_key_add(name
= 'Basis')
437 addShapeKeys(target
.curve
, shapekeys
, space
)
441 if(path
.curve
!= target
.curve
):
442 safeRemoveObj(path
.curve
)
444 def getSplineSegs(spline
):
445 p
= spline
.bezier_points
446 segs
= [Segment(p
[i
-1].co
, p
[i
-1].handle_right
, p
[i
].handle_left
, p
[i
].co
) \
447 for i
in range(1, len(p
))]
448 if(spline
.use_cyclic_u
):
449 segs
.append(Segment(p
[-1].co
, p
[-1].handle_right
, p
[0].handle_left
, p
[0].co
))
452 def subdivideSeg(origSeg
, noSegs
):
458 segLen
= origSeg
.length
/ noSegs
460 for i
in range(0, noSegs
-1):
461 t
= float(i
+1) / noSegs
462 seg
= origSeg
.partialSeg(oldT
, t
)
466 seg
= origSeg
.partialSeg(oldT
, 1)
472 def getSubdivCntPerSeg(part
, toAddCnt
):
475 def __init__(self
, idx
, seg
):
478 self
.length
= seg
.length
481 def __init__(self
, part
):
483 self
.segCnt
= len(part
.getSegs())
484 for idx
, seg
in enumerate(part
.getSegs()):
485 self
.segList
.append(SegWrapper(idx
, seg
))
487 partWrapper
= PartWrapper(part
)
488 partLen
= part
.length
489 avgLen
= partLen
/ (partWrapper
.segCnt
+ toAddCnt
)
491 segsToDivide
= [sr
for sr
in partWrapper
.segList
if sr
.seg
.length
>= avgLen
]
492 segToDivideCnt
= len(segsToDivide
)
493 avgLen
= sum(sr
.seg
.length
for sr
in segsToDivide
) / (segToDivideCnt
+ toAddCnt
)
495 segsToDivide
= sorted(segsToDivide
, key
=lambda x
: x
.length
, reverse
= True)
497 cnts
= [0] * partWrapper
.segCnt
501 for i
in range(0, segToDivideCnt
):
502 segLen
= segsToDivide
[i
].seg
.length
504 divideCnt
= int(round(segLen
/avgLen
)) - 1
508 if((addedCnt
+ divideCnt
) >= toAddCnt
):
509 cnts
[segsToDivide
[i
].idx
] = toAddCnt
- addedCnt
513 cnts
[segsToDivide
[i
].idx
] = divideCnt
515 addedCnt
+= divideCnt
517 #TODO: Verify if needed
518 while(toAddCnt
> addedCnt
):
519 for i
in range(0, segToDivideCnt
):
520 cnts
[segsToDivide
[i
].idx
] += 1
522 if(toAddCnt
== addedCnt
):
527 #Just distribute equally; this is likely a rare condition. So why complicate?
528 def distributeCnt(maxSegCntsByPart
, startIdx
, extraCnt
):
530 elemCnt
= len(maxSegCntsByPart
) - startIdx
531 cntPerElem
= floor(extraCnt
/ elemCnt
)
532 remainder
= extraCnt
% elemCnt
534 for i
in range(startIdx
, len(maxSegCntsByPart
)):
535 maxSegCntsByPart
[i
] += cntPerElem
536 if(i
< remainder
+ startIdx
):
537 maxSegCntsByPart
[i
] += 1
539 #Make all the paths to have the maximum number of segments in the set
541 def addMissingSegs(selPaths
, byPart
):
542 maxSegCntsByPart
= []
546 sortedPaths
= sorted(selPaths
, key
= lambda c
: -len(c
.parts
))
548 for i
, path
in enumerate(sortedPaths
):
550 segCnt
= path
.getPartView().getSegCnt()
551 if(segCnt
> maxSegCnt
):
555 for j
, part
in enumerate(path
.parts
):
556 partSegCnt
= part
.getSegCnt()
557 resSegCnt
[i
].append(partSegCnt
)
560 if(j
== len(maxSegCntsByPart
)):
561 maxSegCntsByPart
.append(partSegCnt
)
563 #last part of this path, but other paths in set have more parts
564 elif((j
== len(path
.parts
) - 1) and
565 len(maxSegCntsByPart
) > len(path
.parts
)):
567 remainingSegs
= sum(maxSegCntsByPart
[j
:])
568 if(partSegCnt
<= remainingSegs
):
569 resSegCnt
[i
][j
] = remainingSegs
571 #This part has more segs than the sum of the remaining part segs
572 #So distribute the extra count
573 distributeCnt(maxSegCntsByPart
, j
, (partSegCnt
- remainingSegs
))
575 #Also, adjust the seg count of the last part of the previous
576 #segments that had fewer than max number of parts
577 for k
in range(0, i
):
578 if(len(sortedPaths
[k
].parts
) < len(maxSegCntsByPart
)):
579 totalSegs
= sum(maxSegCntsByPart
)
580 existingSegs
= sum(maxSegCntsByPart
[:len(sortedPaths
[k
].parts
)-1])
581 resSegCnt
[k
][-1] = totalSegs
- existingSegs
583 elif(partSegCnt
> maxSegCntsByPart
[j
]):
584 maxSegCntsByPart
[j
] = partSegCnt
585 for i
, path
in enumerate(sortedPaths
):
588 partView
= path
.getPartView()
589 segCnt
= partView
.getSegCnt()
590 diff
= maxSegCnt
- segCnt
593 cnts
= getSubdivCntPerSeg(partView
, diff
)
595 for j
in range(0, len(path
.parts
)):
598 for k
, seg
in enumerate(part
.getSegs()):
599 numSubdivs
= cnts
[cumulSegIdx
] + 1
600 newSegs
+= subdivideSeg(seg
, numSubdivs
)
603 path
.parts
[j
] = Part(path
, newSegs
, part
.isClosed
)
605 for j
in range(0, len(path
.parts
)):
609 partSegCnt
= part
.getSegCnt()
611 #TODO: Adding everything in the last part?
612 if(j
== (len(path
.parts
)-1) and
613 len(maxSegCntsByPart
) > len(path
.parts
)):
614 diff
= resSegCnt
[i
][j
] - partSegCnt
616 diff
= maxSegCntsByPart
[j
] - partSegCnt
619 cnts
= getSubdivCntPerSeg(part
, diff
)
621 for k
, seg
in enumerate(part
.getSegs()):
623 subdivCnt
= cnts
[k
] + 1 #1 for the existing one
624 newSegs
+= subdivideSeg(seg
, subdivCnt
)
626 #isClosed won't be used, but let's update anyway
627 path
.parts
[j
] = Part(path
, newSegs
, part
.isClosed
)
629 #TODO: Simplify (Not very readable)
630 def alignPath(path
, matchParts
, matchCriteria
, alignBy
, alignValues
):
632 parts
= path
.parts
[:]
634 if(matchParts
== 'custom'):
635 fnMap
= {'vCnt' : lambda part
: -1 * part
.getSegCnt(), \
636 'bbArea': lambda part
: -1 * part
.bboxSurfaceArea(worldSpace
= True), \
637 'bbHeight' : lambda part
: -1 * part
.getBBHeight(worldSpace
= True), \
638 'bbWidth' : lambda part
: -1 * part
.getBBWidth(worldSpace
= True), \
639 'bbDepth' : lambda part
: -1 * part
.getBBDepth(worldSpace
= True)
642 for criterion
in matchCriteria
:
643 fn
= fnMap
.get(criterion
)
645 minmax
= criterion
[:3] == 'max' #0 if min; 1 if max
646 axisIdx
= ord(criterion
[3:]) - ord('X')
648 fn
= eval('lambda part: part.getBBox(worldSpace = True)[' + \
649 str(minmax
) + '][' + str(axisIdx
) + ']')
651 matchPartCmpFns
.append(fn
)
653 def comparer(left
, right
):
654 for fn
in matchPartCmpFns
:
658 if(floatCmpWithMargin(a
, b
)):
661 return (a
> b
) - ( a
< b
) #No cmp in python3
665 parts
= sorted(parts
, key
= cmp_to_key(comparer
))
668 if(alignBy
== 'vertCo'):
669 def evalCmp(criteria
, pt1
, pt2
):
670 if(len(criteria
) == 0):
673 minmax
= criteria
[0][0]
674 axisIdx
= criteria
[0][1]
678 if(floatCmpWithMargin(val1
, val2
)):
679 criteria
= criteria
[:]
681 return evalCmp(criteria
, pt1
, pt2
)
683 return val1
< val2
if minmax
== 'min' else val1
> val2
685 alignCri
= [[a
[:3], ord(a
[3:]) - ord('X')] for a
in alignValues
]
686 alignCmpFn
= lambda pt1
, pt2
, curve
: (evalCmp(alignCri
, \
687 curve
.matrix_world
@ pt1
, curve
.matrix_world
@ pt2
))
692 for i
in range(0, len(parts
)):
693 #Only truly closed parts
694 if(alignCmpFn
!= None and parts
[i
].isClosed
):
695 for j
in range(0, parts
[i
].getSegCnt()):
696 seg
= parts
[i
].getSeg(j
)
697 if(j
== 0 or alignCmpFn(seg
.start
, startPt
, path
.curve
)):
701 path
.parts
[i
]= Part(path
, parts
[i
].getSegsCopy(startIdx
, None) + \
702 parts
[i
].getSegsCopy(None, startIdx
), parts
[i
].isClosed
)
704 path
.parts
[i
] = parts
[i
]
706 #TODO: Other shape key attributes like interpolation...?
707 def getExistingShapeKeyPaths(path
):
711 if(obj
.data
.shape_keys
!= None):
712 keyblocks
= obj
.data
.shape_keys
.key_blocks
[:]
713 for key
in keyblocks
:
714 datacopy
= obj
.data
.copy()
716 for spline
in datacopy
.splines
:
717 for pt
in spline
.bezier_points
:
718 pt
.co
= key
.data
[i
].co
719 pt
.handle_left
= key
.data
[i
].handle_left
720 pt
.handle_right
= key
.data
[i
].handle_right
722 paths
.append(Path(obj
, datacopy
, key
.name
))
725 def addShapeKeys(curve
, paths
, space
):
727 key
= curve
.shape_key_add(name
= path
.name
)
728 pts
= [pt
for pset
in path
.getBezierPtsBySpline() for pt
in pset
]
729 for i
, pt
in enumerate(pts
):
730 if(space
== 'worldspace'):
731 pt
= [curve
.matrix_world
.inverted() @ (path
.curve
.matrix_world
@ p
) for p
in pt
]
732 key
.data
[i
].co
= pt
[0]
733 key
.data
[i
].handle_left
= pt
[1]
734 key
.data
[i
].handle_right
= pt
[2]
737 def safeRemoveObj(obj
):
739 collections
= obj
.users_collection
741 for c
in collections
:
742 c
.objects
.unlink(obj
)
744 if(obj
.name
in bpy
.context
.scene
.collection
.objects
):
745 bpy
.context
.scene
.collection
.objects
.unlink(obj
)
747 if(obj
.data
.users
== 1):
748 if(obj
.type == 'CURVE'):
749 bpy
.data
.curves
.remove(obj
.data
) #This also removes object?
750 elif(obj
.type == 'MESH'):
751 bpy
.data
.meshes
.remove(obj
.data
)
753 bpy
.data
.objects
.remove(obj
)
758 def markVertHandler(self
, context
):
760 bpy
.ops
.wm
.mark_vertex()
763 #################### UI and Registration ####################
765 class AssignShapeKeysOp(Operator
):
766 bl_idname
= "object.assign_shape_keys"
767 bl_label
= "Assign Shape Keys"
768 bl_options
= {'REGISTER', 'UNDO'}
770 def execute(self
, context
):
771 params
= context
.window_manager
.AssignShapeKeyParams
772 removeOriginal
= params
.removeOriginal
775 matchParts
= params
.matchParts
776 matchCri1
= params
.matchCri1
777 matchCri2
= params
.matchCri2
778 matchCri3
= params
.matchCri3
780 alignBy
= params
.alignCos
781 alignVal1
= params
.alignVal1
782 alignVal2
= params
.alignVal2
783 alignVal3
= params
.alignVal3
785 targetObj
= bpy
.context
.active_object
786 shapekeyObjs
= [obj
for obj
in bpy
.context
.selected_objects
if isBezier(obj
) \
787 and obj
!= targetObj
]
789 if(targetObj
!= None and isBezier(targetObj
) and len(shapekeyObjs
) > 0):
790 main(targetObj
, shapekeyObjs
, removeOriginal
, space
, \
791 matchParts
, [matchCri1
, matchCri2
, matchCri3
], \
792 alignBy
, [alignVal1
, alignVal2
, alignVal3
])
797 class MarkerController
:
798 drawHandlerRef
= None
800 ptColor
= (0, .8, .8, 1)
802 def createSMMap(self
, context
):
803 objs
= context
.selected_objects
806 if(not isBezier(curve
)):
809 smMap
[curve
.name
] = {}
810 mw
= curve
.matrix_world
811 for splineIdx
, spline
in enumerate(curve
.data
.splines
):
812 if(not spline
.use_cyclic_u
):
815 #initialize to the curr start vert co and idx
816 smMap
[curve
.name
][splineIdx
] = \
817 [mw
@ curve
.data
.splines
[splineIdx
].bezier_points
[0].co
, 0]
819 for pt
in spline
.bezier_points
:
820 pt
.select_control_point
= False
822 if(len(smMap
[curve
.name
]) == 0):
823 del smMap
[curve
.name
]
827 def createBatch(self
, context
):
828 positions
= [s
[0] for cn
in self
.smMap
.values() for s
in cn
.values()]
829 colors
= [MarkerController
.ptColor
for i
in range(0, len(positions
))]
831 self
.batch
= batch_for_shader(self
.shader
, \
832 "POINTS", {"pos": positions
, "color": colors
})
835 context
.area
.tag_redraw()
837 def drawHandler(self
):
838 gpu
.state
.point_size_set(MarkerController
.defPointSize
)
839 self
.batch
.draw(self
.shader
)
841 def removeMarkers(self
, context
):
842 if(MarkerController
.drawHandlerRef
!= None):
843 bpy
.types
.SpaceView3D
.draw_handler_remove(MarkerController
.drawHandlerRef
, \
846 if(context
.area
and hasattr(context
.space_data
, 'region_3d')):
847 context
.area
.tag_redraw()
849 MarkerController
.drawHandlerRef
= None
853 def __init__(self
, context
):
854 self
.smMap
= self
.createSMMap(context
)
855 self
.shader
= gpu
.shader
.from_builtin('3D_FLAT_COLOR')
858 MarkerController
.drawHandlerRef
= \
859 bpy
.types
.SpaceView3D
.draw_handler_add(self
.drawHandler
, \
860 (), "WINDOW", "POST_VIEW")
862 self
.createBatch(context
)
864 def saveStartVerts(self
):
865 for curveName
in self
.smMap
.keys():
866 curve
= bpy
.data
.objects
[curveName
]
867 splines
= curve
.data
.splines
868 spMap
= self
.smMap
[curveName
]
870 for splineIdx
in spMap
.keys():
871 markerInfo
= spMap
[splineIdx
]
872 if(markerInfo
[1] != 0):
873 pts
= splines
[splineIdx
].bezier_points
874 loc
, idx
= markerInfo
[0], markerInfo
[1]
877 ptCopy
= [[p
.co
.copy(), p
.handle_right
.copy(), \
878 p
.handle_left
.copy(), p
.handle_right_type
, \
879 p
.handle_left_type
] for p
in pts
]
881 for i
, pt
in enumerate(pts
):
882 srcIdx
= (idx
+ i
) % cnt
885 #Must set the types first
886 pt
.handle_right_type
= p
[3]
887 pt
.handle_left_type
= p
[4]
889 pt
.handle_right
= p
[1]
890 pt
.handle_left
= p
[2]
892 def updateSMMap(self
):
893 for curveName
in self
.smMap
.keys():
894 curve
= bpy
.data
.objects
[curveName
]
895 spMap
= self
.smMap
[curveName
]
896 mw
= curve
.matrix_world
898 for splineIdx
in spMap
.keys():
899 markerInfo
= spMap
[splineIdx
]
900 loc
, idx
= markerInfo
[0], markerInfo
[1]
901 pts
= curve
.data
.splines
[splineIdx
].bezier_points
903 selIdxs
= [x
for x
in range(0, len(pts
)) \
904 if pts
[x
].select_control_point
== True]
906 selIdx
= selIdxs
[0] if(len(selIdxs
) > 0 ) else idx
907 co
= mw
@ pts
[selIdx
].co
908 self
.smMap
[curveName
][splineIdx
] = [co
, selIdx
]
910 def deselectAll(self
):
911 for curveName
in self
.smMap
.keys():
912 curve
= bpy
.data
.objects
[curveName
]
913 for spline
in curve
.data
.splines
:
914 for pt
in spline
.bezier_points
:
915 pt
.select_control_point
= False
917 def getSpaces3D(context
):
918 areas3d
= [area
for area
in context
.window
.screen
.areas \
919 if area
.type == 'VIEW_3D']
921 return [s
for a
in areas3d
for s
in a
.spaces
if s
.type == 'VIEW_3D']
923 def hideHandles(context
):
925 spaces
= MarkerController
.getSpaces3D(context
)
927 if(hasattr(s
.overlay
, 'show_curve_handles')):
928 states
.append(s
.overlay
.show_curve_handles
)
929 s
.overlay
.show_curve_handles
= False
930 elif(hasattr(s
.overlay
, 'display_handle')): # 2.90
931 states
.append(s
.overlay
.display_handle
)
932 s
.overlay
.display_handle
= 'NONE'
935 def resetShowHandleState(context
, handleStates
):
936 spaces
= MarkerController
.getSpaces3D(context
)
937 for i
, s
in enumerate(spaces
):
938 if(hasattr(s
.overlay
, 'show_curve_handles')):
939 s
.overlay
.show_curve_handles
= handleStates
[i
]
940 elif(hasattr(s
.overlay
, 'display_handle')): # 2.90
941 s
.overlay
.display_handle
= handleStates
[i
]
944 class ModalMarkSegStartOp(Operator
):
945 bl_description
= "Mark Vertex"
946 bl_idname
= "wm.mark_vertex"
947 bl_label
= "Mark Start Vertex"
949 def cleanup(self
, context
):
950 wm
= context
.window_manager
951 wm
.event_timer_remove(self
._timer
)
952 self
.markerState
.removeMarkers(context
)
953 MarkerController
.resetShowHandleState(context
, self
.handleStates
)
954 context
.window_manager
.AssignShapeKeyParams
.markVertex
= False
956 def modal (self
, context
, event
):
957 params
= context
.window_manager
.AssignShapeKeyParams
959 if(context
.mode
== 'OBJECT' or event
.type == "ESC" or\
960 not context
.window_manager
.AssignShapeKeyParams
.markVertex
):
961 self
.cleanup(context
)
964 elif(event
.type == "RET"):
965 self
.markerState
.saveStartVerts()
966 self
.cleanup(context
)
969 if(event
.type == 'TIMER'):
970 self
.markerState
.updateSMMap()
971 self
.markerState
.createBatch(context
)
973 return {"PASS_THROUGH"}
975 def execute(self
, context
):
976 #TODO: Why such small step?
977 self
._timer
= context
.window_manager
.event_timer_add(time_step
= 0.0001, \
978 window
= context
.window
)
980 context
.window_manager
.modal_handler_add(self
)
981 self
.markerState
= MarkerController(context
)
983 #Hide so that users don't accidentally select handles instead of points
984 self
.handleStates
= MarkerController
.hideHandles(context
)
986 return {"RUNNING_MODAL"}
989 class AssignShapeKeyParams(bpy
.types
.PropertyGroup
):
991 removeOriginal
: BoolProperty(name
= "Remove Shape Key Objects", \
992 description
= "Remove shape key objects after assigning to target", \
995 space
: EnumProperty(name
= "Space", \
996 items
= [('worldspace', 'World Space', 'worldspace'),
997 ('localspace', 'Local Space', 'localspace')], \
998 description
= 'Space that shape keys are evluated in')
1000 alignCos
: EnumProperty(name
="Vertex Alignment", items
= \
1001 [("-None-", 'Manual Alignment', "Align curve segments based on starting vertex"), \
1002 ('vertCo', 'Vertex Coordinates', 'Align curve segments based on vertex coordinates')], \
1003 description
= 'Start aligning the vertices of target and shape keys from',
1006 alignVal1
: EnumProperty(name
="Value 1",
1007 items
= alignList
, default
= 'minX', description
='First align criterion')
1009 alignVal2
: EnumProperty(name
="Value 2",
1010 items
= alignList
, default
= 'maxY', description
='Second align criterion')
1012 alignVal3
: EnumProperty(name
="Value 3",
1013 items
= alignList
, default
= 'minZ', description
='Third align criterion')
1015 matchParts
: EnumProperty(name
="Match Parts", items
= \
1016 [("-None-", 'None', "Don't match parts"), \
1017 ('default', 'Default', 'Use part (spline) order as in curve'), \
1018 ('custom', 'Custom', 'Use one of the custom criteria for part matching')], \
1019 description
='Match disconnected parts', default
= 'default')
1021 matchCri1
: EnumProperty(name
="Value 1",
1022 items
= matchList
, default
= 'minX', description
='First match criterion')
1024 matchCri2
: EnumProperty(name
="Value 2",
1025 items
= matchList
, default
= 'maxY', description
='Second match criterion')
1027 matchCri3
: EnumProperty(name
="Value 3",
1028 items
= matchList
, default
= 'minZ', description
='Third match criterion')
1030 markVertex
: BoolProperty(name
="Mark Starting Vertices", \
1031 description
='Mark first vertices in all splines of selected curves', \
1032 default
= False, update
= markVertHandler
)
1035 class AssignShapeKeysPanel(Panel
):
1037 bl_label
= "Curve Shape Keys"
1038 bl_idname
= "CURVE_PT_assign_shape_keys"
1039 bl_space_type
= 'VIEW_3D'
1040 bl_region_type
= 'UI'
1041 bl_category
= "Edit"
1042 bl_options
= {'DEFAULT_CLOSED'}
1045 def poll(cls
, context
):
1046 return context
.mode
in {'OBJECT', 'EDIT_CURVE'}
1048 def draw(self
, context
):
1050 layout
= self
.layout
1051 layout
.label(text
='Morph Curves:')
1052 col
= layout
.column()
1053 params
= context
.window_manager
.AssignShapeKeyParams
1055 if(context
.mode
== 'OBJECT'):
1057 row
.prop(params
, "removeOriginal")
1060 row
.prop(params
, "space")
1063 row
.prop(params
, "alignCos")
1065 if(params
.alignCos
== 'vertCo'):
1067 row
.prop(params
, "alignVal1")
1068 row
.prop(params
, "alignVal2")
1069 row
.prop(params
, "alignVal3")
1072 row
.prop(params
, "matchParts")
1074 if(params
.matchParts
== 'custom'):
1076 row
.prop(params
, "matchCri1")
1077 row
.prop(params
, "matchCri2")
1078 row
.prop(params
, "matchCri3")
1081 row
.operator("object.assign_shape_keys")
1083 col
.prop(params
, "markVertex", \
1087 def updatePanel(self
, context
):
1089 panel
= AssignShapeKeysPanel
1090 if "bl_rna" in panel
.__dict
__:
1091 bpy
.utils
.unregister_class(panel
)
1093 panel
.bl_category
= context
.preferences
.addons
[__name__
].preferences
.category
1094 bpy
.utils
.register_class(panel
)
1096 except Exception as e
:
1097 print("Assign Shape Keys: Updating Panel locations has failed", e
)
1099 class AssignShapeKeysPreferences(AddonPreferences
):
1100 bl_idname
= __name__
1102 category
: StringProperty(
1103 name
= "Tab Category",
1104 description
= "Choose a name for the category of the panel",
1106 update
= updatePanel
1109 def draw(self
, context
):
1110 layout
= self
.layout
1113 col
.label(text
="Tab Category:")
1114 col
.prop(self
, "category", text
="")
1116 # registering and menu integration
1118 bpy
.utils
.register_class(AssignShapeKeysPanel
)
1119 bpy
.utils
.register_class(AssignShapeKeysOp
)
1120 bpy
.utils
.register_class(AssignShapeKeyParams
)
1121 bpy
.types
.WindowManager
.AssignShapeKeyParams
= \
1122 bpy
.props
.PointerProperty(type=AssignShapeKeyParams
)
1123 bpy
.utils
.register_class(ModalMarkSegStartOp
)
1124 bpy
.utils
.register_class(AssignShapeKeysPreferences
)
1125 updatePanel(None, bpy
.context
)
1128 bpy
.utils
.unregister_class(AssignShapeKeysOp
)
1129 bpy
.utils
.unregister_class(AssignShapeKeysPanel
)
1130 del bpy
.types
.WindowManager
.AssignShapeKeyParams
1131 bpy
.utils
.unregister_class(AssignShapeKeyParams
)
1132 bpy
.utils
.unregister_class(ModalMarkSegStartOp
)
1133 bpy
.utils
.unregister_class(AssignShapeKeysPreferences
)
1135 if __name__
== "__main__":