mightyscape-1.2/extensions/fablabchemnitz/bezier_envelope/bezier_envelope.py

409 lines
15 KiB
Python
Raw Normal View History

#!/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
class BezierEnvelope(inkex.EffectExtension):
segmentTypes = ["move","line","quad","cubic","close"]
def effect(self):
if len(self.options.ids) < 2:
inkex.errormsg("Two paths must be selected. The 1st is the letter, the 2nd is the envelope and must have 4 sides.")
exit()
letterElement = self.svg.selected[self.options.ids[0]]
envelopeElement = self.svg.selected[self.options.ids[1]]
if letterElement.get('inkscape:original-d') or envelopeElement.get('inkscape:original-d'):
inkex.errormsg("One or both selected paths have attribute 'inkscape:original-d' which points to Live Path Effects (LPE). Please convert to regular path.")
exit()
if letterElement.tag != inkex.addNS('path','svg') or envelopeElement.tag != inkex.addNS('path','svg'):
inkex.errormsg("Both letter and envelope must be SVG paths.")
exit()
axes = extractMorphAxes(Path( envelopeElement.get('d') ).to_arrays())
if axes is None:
inkex.errormsg("No axes found on envelope.")
exit()
axisCount = len(axes)
if axisCount < 4:
inkex.errormsg("The envelope path has less than 4 segments.")
exit()
for i in range( 0, 4 ):
if axes[i] is None:
inkex.errormsg("axis[%i] is None. Check if envelope has at least 4 nodes (closed path) or 5 nodes (open path)." % i)
exit()
# 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.
def morphPath(path, axes):
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"
elif segmentType == "A":
inkex.errormsg("Sorry, arcs are not supported in envelope or letter path!")
exit()
else:
inkex.errormsg("unsupported segment type: %s" % (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 is None:
return None
if cmd == "A":
inkex.errormsg("Sorry, arcs are not supported in envelope or letter path!")
return None
elif 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:
inkex.errormsg("Unsupported segment type in envelope path: %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]]
if __name__ == '__main__':
BezierEnvelope().run()