2020-07-30 01:16:18 +02:00
#!/usr/bin/env python3
'''
Bezier Envelope extension for Inkscape
Copyright ( C ) 2009 Gerrit Karius
This program is free software ; you can redistribute it and / or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation ; either version 2
of the License , or ( at your option ) any later version .
This program is distributed in the hope that it will be useful ,
but WITHOUT ANY WARRANTY ; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE . See the
GNU General Public License for more details .
You should have received a copy of the GNU General Public License
along with this program ; if not , write to the Free Software
Foundation , Inc . , 51 Franklin Street , Fifth Floor , Boston , MA 02110 - 1301 , USA .
About the Bezier Envelope extension :
This extension implements Bezier enveloping .
It takes an arbitrary path ( the " letter " ) and a 4 - sided path ( the " envelope " ) as input .
The envelope must be 4 segments long . Unless the letter is to be rotated or flipped ,
the envelope should begin at the upper left corner and be drawn clockwise .
The extension then attempts to squeeze the letter into the envelope
by rearranging all anchor and handle points of the letter ' s path.
In order to do this , the bounding box of the letter is used .
All anchor and bezier handle points get new x and y coordinates between 0 % and 100 %
according to their place inside the bounding box .
The 4 sides of the envelope are then interpreted as deformed axes .
Points at 0 % or 100 % could be placed along these axes , but because most points
are somewhere inside the bounding box , some tweening of the axes must be done .
The function mapPointsToMorph does the tweening .
Say , some point is at x = 30 % , y = 40 % .
For the tweening , the function tweenCubic first calculates a straight tween
of the y axis at the x percentage of 30 % .
This tween axis now floats somewhere between the y axis keys at the x percentage ,
but is not necessarily inside the envelope , because the x axes are not straight .
Now , the end points on the two x axes at 30 % are calculated . The function match ( )
takes these points and calculates a " stretch " transform which maps the two anchor
points of the y axis tween to the two points on the x axes by rotating the tween and
stretching it along its endpoints . This transform is then applied to the handle points ,
to get the entire tweened y axis to its x tweened position .
Last , the point at the y percentage 40 % of this y axis tween is calculated .
That is the final point of the enveloped letter .
Finally , after all of the letter ' s points have been recalculated in this manner,
the resulting path is taken and replaces the letter ' s original path.
TODO :
* Currently , both letter and envelope must be paths to work .
- > Arbitrary other shapes like circles and rectangles should be interpreted as paths .
* It should be possible to select several letters , and squeeze them into one envelope as a group .
* It should be possible to insert a clone of the letter , instead of replacing it .
* This program was originally written in Java . Maybe for some code , Python shortcuts can be used .
I hope the comments are not too verbose . Enjoy !
'''
import inkex
from inkex import Transform
from inkex . paths import Path
import math
import sys
import ffgeom
2021-04-04 01:51:59 +02:00
class BezierEnvelope ( inkex . EffectExtension ) :
2020-07-30 01:16:18 +02:00
segmentTypes = [ " move " , " line " , " quad " , " cubic " , " close " ]
def effect ( self ) :
if len ( self . options . ids ) < 2 :
2021-04-15 17:03:47 +02:00
inkex . errormsg ( " Two paths must be selected. The 1st is the letter, the 2nd is the envelope and must have 4 sides. " )
2020-07-30 01:16:18 +02:00
exit ( )
letterElement = self . svg . selected [ self . options . ids [ 0 ] ]
envelopeElement = self . svg . selected [ self . options . ids [ 1 ] ]
2021-04-08 22:58:17 +02:00
if letterElement . get ( ' inkscape:original-d ' ) or envelopeElement . get ( ' inkscape:original-d ' ) :
2021-04-15 17:03:47 +02:00
inkex . errormsg ( " One or both selected paths have attribute ' inkscape:original-d ' which points to Live Path Effects (LPE). Please convert to regular path. " )
2021-04-08 22:58:17 +02:00
exit ( )
2020-07-30 01:16:18 +02:00
if letterElement . tag != inkex . addNS ( ' path ' , ' svg ' ) or envelopeElement . tag != inkex . addNS ( ' path ' , ' svg ' ) :
2021-04-15 17:03:47 +02:00
inkex . errormsg ( " Both letter and envelope must be SVG paths. " )
2020-07-30 01:16:18 +02:00
exit ( )
2021-04-15 17:03:47 +02:00
axes = extractMorphAxes ( Path ( envelopeElement . get ( ' d ' ) ) . to_arrays ( ) )
2020-07-30 01:16:18 +02:00
if axes is None :
2021-04-15 17:03:47 +02:00
inkex . errormsg ( " No axes found on envelope. " )
exit ( )
2020-07-30 01:16:18 +02:00
axisCount = len ( axes )
if axisCount < 4 :
2021-04-15 17:03:47 +02:00
inkex . errormsg ( " The envelope path has less than 4 segments. " )
exit ( )
2020-07-30 01:16:18 +02:00
for i in range ( 0 , 4 ) :
if axes [ i ] is None :
2021-04-15 17:03:47 +02:00
inkex . errormsg ( " axis[ %i ] is None " % i )
exit ( )
2020-07-30 01:16:18 +02:00
# morph the enveloped element according to the axes
morph_element ( letterElement , envelopeElement , axes ) ;
def morph_element ( letterElement , envelopeElement , axes ) :
path = Path ( letterElement . get ( ' d ' ) ) . to_arrays ( )
morphedPath = morphPath ( path , axes )
letterElement . set ( " d " , str ( Path ( morphedPath ) ) )
# Morphs a path into a new path, according to cubic curved bounding axes.
2021-04-15 17:03:47 +02:00
def morphPath ( path , axes ) :
2020-07-30 01:16:18 +02:00
bounds = [ y for x in list ( Path ( path ) . bounding_box ( ) ) for y in list ( x ) ]
assert len ( bounds ) == 4
new_path = [ ]
current = [ 0.0 , 0.0 ]
start = [ 0.0 , 0.0 ]
for cmd , params in path :
segmentType = cmd
points = params
if segmentType == " M " :
start [ 0 ] = points [ 0 ]
start [ 1 ] = points [ 1 ]
segmentType = convertSegmentToCubic ( current , segmentType , points , start )
percentages = [ 0.0 ] * len ( points )
morphed = [ 0.0 ] * len ( points )
numPts = getNumPts ( segmentType )
normalizePoints ( bounds , points , percentages , numPts )
mapPointsToMorph ( axes , percentages , morphed , numPts )
addSegment ( new_path , segmentType , morphed )
if len ( points ) > = 2 :
current [ 0 ] = points [ len ( points ) - 2 ]
current [ 1 ] = points [ len ( points ) - 1 ]
return new_path
def getNumPts ( segmentType ) :
if segmentType == " M " :
return 1
if segmentType == " L " :
return 1
if segmentType == " Q " :
return 2
if segmentType == " C " :
return 3
if segmentType == " Z " :
return 0
return - 1
def addSegment ( path , segmentType , points ) :
path . append ( [ segmentType , points ] )
# Converts visible path segments (Z,L,Q) into absolute cubic segments (C).
def convertSegmentToCubic ( current , segmentType , points , start ) :
if segmentType == " H " :
# print(current, points, start)
assert len ( points ) == 1
points . insert ( 0 , current [ 0 ] )
# points[0] += current[0]
# print(segmentType, current, points, start)
return convertSegmentToCubic ( current , " L " , points , start )
elif segmentType == " V " :
# print(points)
assert len ( points ) == 1
points . append ( current [ 1 ] )
# points[1] += current[1]
# print(segmentType, current, points, start)
return convertSegmentToCubic ( current , " L " , points , start )
if segmentType == " M " :
return " M " ;
if segmentType == " C " :
return " C " ;
elif segmentType == " Z " :
for i in range ( 0 , 6 ) :
points . append ( 0.0 )
points [ 4 ] = start [ 0 ]
points [ 5 ] = start [ 1 ]
thirdX = ( points [ 4 ] - current [ 0 ] ) / 3.0
thirdY = ( points [ 5 ] - current [ 1 ] ) / 3.0
points [ 2 ] = points [ 4 ] - thirdX
points [ 3 ] = points [ 5 ] - thirdY
points [ 0 ] = current [ 0 ] + thirdX
points [ 1 ] = current [ 1 ] + thirdY
return " C "
elif segmentType == " L " :
for i in range ( 0 , 4 ) :
points . append ( 0.0 )
points [ 4 ] = points [ 0 ]
points [ 5 ] = points [ 1 ]
thirdX = ( points [ 4 ] - current [ 0 ] ) / 3.0
thirdY = ( points [ 5 ] - current [ 1 ] ) / 3.0
points [ 2 ] = points [ 4 ] - thirdX
points [ 3 ] = points [ 5 ] - thirdY
points [ 0 ] = current [ 0 ] + thirdX
points [ 1 ] = current [ 1 ] + thirdY
return " C "
elif segmentType == " Q " :
for i in range ( 0 , 2 ) :
points . append ( 0.0 )
firstThirdX = ( points [ 0 ] - current [ 0 ] ) * 2.0 / 3.0
firstThirdY = ( points [ 1 ] - current [ 1 ] ) * 2.0 / 3.0
secondThirdX = ( points [ 2 ] - points [ 0 ] ) * 2.0 / 3.0
secondThirdY = ( points [ 3 ] - points [ 1 ] ) * 2.0 / 3.0
points [ 4 ] = points [ 2 ]
points [ 5 ] = points [ 3 ]
points [ 0 ] = current [ 0 ] + firstThirdX
points [ 1 ] = current [ 1 ] + firstThirdY
points [ 2 ] = points [ 2 ] - secondThirdX
points [ 3 ] = points [ 3 ] - secondThirdY
return " C "
else :
sys . stderr . write ( " unsupported segment type: %s \n " % ( segmentType ) )
return segmentType
# Normalizes the points of a path segment, so that they are expressed as percentage coordinates
# relative to the bounding box axes of the total shape.
# @param bounds The bounding box of the shape.
# @param points The points of the segment.
# @param percentages The returned points in normalized percentage form.
# @param numPts
def normalizePoints ( bounds , points , percentages , numPts ) :
# bounds has structure xmin,xMax,ymin,yMax
xmin , xMax , ymin , yMax = bounds
for i in range ( 0 , numPts ) :
x = i * 2
y = i * 2 + 1
percentages [ x ] = ( points [ x ] - xmin ) / ( xMax - xmin )
percentages [ y ] = ( points [ y ] - ymin ) / ( yMax - ymin )
# Extracts 4 axes from a path. It is assumed that the path starts with a move, followed by 4 cubic paths.
# The extraction reverses the last 2 axes, so that they run in parallel with the first 2.
# @param path The path that is formed by the axes.
# @return The definition points of the 4 cubic path axes as float arrays, bundled in another array.
def extractMorphAxes ( path ) :
points = [ ]
current = [ 0.0 , 0.0 ]
start = [ 0.0 , 0.0 ]
# the curved axis definitions go in here
axes = [ None ] * 4
i = 0
for cmd , params in path :
points = params
cmd = convertSegmentToCubic ( current , cmd , points , start )
if cmd == " M " :
current [ 0 ] = points [ 0 ]
current [ 1 ] = points [ 1 ]
start [ 0 ] = points [ 0 ]
start [ 1 ] = points [ 1 ]
elif cmd == " C " :
# 1st cubic becomes x axis 0
# 2nd cubic becomes y axis 1
# 3rd cubic becomes x axis 2 and is reversed
# 4th cubic becomes y axis 3 and is reversed
if i % 2 == 0 :
index = i
else :
index = 4 - i
if ( i < 2 ) :
# axes 1 and 2
axes [ index ] = [ current [ 0 ] , current [ 1 ] , points [ 0 ] , points [ 1 ] , points [ 2 ] , points [ 3 ] , points [ 4 ] , points [ 5 ] ]
elif ( i < 4 ) :
# axes 3 and 4
axes [ index ] = [ points [ 4 ] , points [ 5 ] , points [ 2 ] , points [ 3 ] , points [ 0 ] , points [ 1 ] , current [ 0 ] , current [ 1 ] ]
else :
# more than 4 axes - hopefully it was an unnecessary trailing Z
{ }
current [ 0 ] = points [ 4 ]
current [ 1 ] = points [ 5 ]
i = i + 1
elif cmd == " Z " :
#do nothing
{ }
else :
raise Exception ( " Unsupported segment type: %s " % cmd )
return None
return axes
# Projects points in percentage coordinates into a morphed coordinate system that is framed
# by 2 x cubic curves (along the x axis) and 2 y cubic curves (along the y axis).
# @param axes The x and y axes of the envelope.
# @param percentage The current segment of the letter in normalized percentage form.
# @param morphed The array to hold the returned morphed path.
# @param numPts The number of points to be transformed.
def mapPointsToMorph ( axes , percentage , morphed , numPts ) :
# rename the axes for legibility
yCubic0 = axes [ 1 ]
yCubic1 = axes [ 3 ]
xCubic0 = axes [ 0 ]
xCubic1 = axes [ 2 ]
# morph each point
for i in range ( 0 , numPts ) :
x = i * 2
y = i * 2 + 1
# tween between the morphed y axes according to the x percentage
tweenedY = tweenCubic ( yCubic0 , yCubic1 , percentage [ x ] )
# get 2 points on the morphed x axes
xSpot0 = pointOnCubic ( xCubic0 , percentage [ x ] )
xSpot1 = pointOnCubic ( xCubic1 , percentage [ x ] )
# create a transform that stretches the y axis tween between these 2 points
yAnchor0 = [ tweenedY [ 0 ] , tweenedY [ 1 ] ]
yAnchor1 = [ tweenedY [ 6 ] , tweenedY [ 7 ] ]
xTransform = match ( yAnchor0 , yAnchor1 , xSpot0 , xSpot1 )
# map the y axis tween to the 2 points by applying the stretch transform
for j in range ( 0 , 4 ) :
x2 = j * 2
y2 = j * 2 + 1
pointOnY = [ tweenedY [ x2 ] , tweenedY [ y2 ] ]
Transform ( xTransform ) . apply_to_point ( pointOnY )
tweenedY [ x2 ] = pointOnY [ 0 ]
tweenedY [ y2 ] = pointOnY [ 1 ]
# get the point on the tweened and transformed y axis according to the y percentage
morphedPoint = pointOnCubic ( tweenedY , percentage [ y ] )
morphed [ x ] = morphedPoint [ 0 ]
morphed [ y ] = morphedPoint [ 1 ]
# Calculates the point on a cubic bezier curve at the given percentage.
def pointOnCubic ( c , t ) :
point = [ 0.0 , 0.0 ]
_t_2 = t * t
_t_3 = _t_2 * t
_1_t = 1 - t
_1_t_2 = _1_t * _1_t
_1_t_3 = _1_t_2 * _1_t
for i in range ( 0 , 2 ) :
point [ i ] = c [ i ] * _1_t_3 + 3 * c [ 2 + i ] * _1_t_2 * t + 3 * c [ 4 + i ] * _1_t * _t_2 + c [ 6 + i ] * _t_3
return point
# Tweens 2 bezier curves in a straightforward way,
# i.e. each of the points on the curve is tweened along a straight line
# between the respective point on key1 and key2.
def tweenCubic ( key1 , key2 , percentage ) :
tween = [ 0.0 ] * len ( key1 )
for i in range ( 0 , len ( key1 ) ) :
tween [ i ] = key1 [ i ] + percentage * ( key2 [ i ] - key1 [ i ] )
return tween
# Calculates a transform that matches 2 points to 2 anchors
# by rotating and scaling (up or down) along the axis that is formed by
# a line between the two points.
def match ( p1 , p2 , a1 , a2 ) :
x = 0
y = 1
# distances
dp = [ p2 [ x ] - p1 [ x ] , p2 [ y ] - p1 [ y ] ]
da = [ a2 [ x ] - a1 [ x ] , a2 [ y ] - a1 [ y ] ]
# angles
angle_p = math . atan2 ( dp [ x ] , dp [ y ] )
angle_a = math . atan2 ( da [ x ] , da [ y ] )
# radians
#rp = math.sqrt( dp[x]*dp[x] + dp[y]*dp[y] )
#ra = math.sqrt( da[x]*da[x] + da[y]*da[y] )
rp = math . hypot ( dp [ x ] , dp [ y ] )
ra = math . hypot ( da [ x ] , da [ y ] )
# scale
scale = ra / rp
# transforms in the order they are applied
t1 = Transform ( " translate( %f , %f ) " % ( - p1 [ x ] , - p1 [ y ] ) ) . matrix
#t2 = Transform( "rotate(%f)"%(-angle_p) ).matrix
#t3 = Transform( "scale(%f,%f)"%(scale,scale) ).matrix
#t4 = Transform( "rotate(%f)"%angle_a ).matrix
t2 = rotateTransform ( - angle_p )
t3 = scale_transform ( scale , scale )
t4 = rotateTransform ( angle_a )
t5 = Transform ( " translate( %f , %f ) " % ( a1 [ x ] , a1 [ y ] ) ) . matrix
# transforms in the order they are multiplied
t = t5
t = Transform ( t ) * Transform ( t4 )
t = Transform ( t ) * Transform ( t3 )
t = Transform ( t ) * Transform ( t2 )
t = Transform ( t ) * Transform ( t1 )
# return the combined transform
return t
def rotateTransform ( a ) :
return [ [ math . cos ( a ) , - math . sin ( a ) , 0 ] , [ math . sin ( a ) , math . cos ( a ) , 0 ] ]
def scale_transform ( sx , sy ) :
return [ [ sx , 0 , 0 ] , [ 0 , sy , 0 ] ]
2020-08-31 21:25:41 +02:00
if __name__ == ' __main__ ' :
BezierEnvelope ( ) . run ( )