This repository has been archived on 2023-03-25. You can view files and clone it, but cannot push or open issues or pull requests.
mightyscape-1.1-deprecated/extensions/fablabchemnitz/flevobezier/flevobezier.py
2021-07-23 02:36:56 +02:00

241 lines
12 KiB
Python

#!/usr/bin/env python3
# Flevobezier: an Inkscape extension fitting Bezier curves
# Parcly Taxel / Jeremy Tan, 2019
# https://gitlab.com/parclytaxel
from __future__ import division
from math import *
from inkex.bezier import bezierpointatt
import inkex
import gettext
from inkex.paths import Path
import sys
def pout(t): sys.exit((gettext.gettext(t)))
class FlevoBezier(inkex.EffectExtension):
def effect(self):
if len(self.svg.selected) == 0: pout("Please select at least one path.")
for obj in self.svg.selected: # The objects are the paths, which may be compound
curr = self.svg.selected[obj]
raw = Path(curr.get("d")).to_arrays()
subpaths, prev = [], 0
for i in range(len(raw)): # Breaks compound paths into simple paths
if raw[i][0] == 'M' and i != 0:
subpaths.append(raw[prev:i])
prev = i
subpaths.append(raw[prev:])
output = ""
for simpath in subpaths:
closed = False
if simpath[-1][0] == 'Z':
closed = True
if simpath[-2][0] == 'L': simpath[-1][1] = simpath[0][1]
else: simpath.pop()
#nodes = [node(simpath[i][1][-2:]) for i in range(len(simpath))]
nodes = []
for i in range(len(simpath)):
if simpath[i][0] == 'V': # vertical and horizontal lines only have one point in args, but 2 are required
#inkex.utils.debug(simpath[i][0])
simpath[i][0]='L' #overwrite V with regular L command
add=simpath[i-1][1][0] #read the X value from previous segment
simpath[i][1].append(simpath[i][1][0]) #add the second (missing) argument by taking argument from previous segment
simpath[i][1][0]=add #replace with recent X after Y was appended
if simpath[i][0] == 'H': # vertical and horizontal lines only have one point in args, but 2 are required
#inkex.utils.debug(simpath[i][0])
simpath[i][0]='L' #overwrite H with regular L command
simpath[i][1].append(simpath[i-1][1][1]) #add the second (missing) argument by taking argument from previous segment
#inkex.utils.debug(simpath[i])
nodes.append(node(simpath[i][1][-2:]))
output += flevobezier(nodes, closed)
curr.set("d", output)
# The main algorithm! Yay!
def flevobezier(points, z):
if len(points) < 2: pout("A curve isn't a point, silly!")
res = []
prevtrail, trail, lead, window = 0, 0, 1, points[:2] # Start with first two points
maybeover = False # Over by error followed by over by angle -> backup
curcurve = [window[0], slide(window[0], window[1], 1 / 3), slide(window[0], window[1], 2 / 3), window[1]] # Current working curve, always a 4-list
while lead + 1 < len(points):
lead += 1
window = points[trail:lead + 1] # Extend the window one more node
v = window[-3] - window[-2]
w = window[-1] - window[-2]
try:
dist(v) / dist(w)
except ZeroDivisionError as e:
pout("Division by zero. Check if your path contains duplicate handles.")
if dotp(v, w) / dist(v) / dist(w) >= 0.5: # 60 degrees or less, over by angle
if maybeover: # backup
newcurve = stress(points[prevtrail:lead])[0]
res[-3:] = newcurve[1:] # replace the last three nodes in res with those of newcurve
trail = lead - 1
maybeover = False
else:
if not res: res += curcurve[:1]
res += curcurve[1:]
prevtrail = trail
trail = lead - 1
window = points[trail:lead + 1]
curcurve = [window[0], slide(window[0], window[1], 1 / 3), slide(window[0], window[1], 2 / 3), window[1]]
else: # then see what to do based on how long the window is
over = False
if len(window) == 3: # Quadratic curve on three nodes stepped to a cubic
t = chords(window)[1]
qcurve = [window[0], (window[1] - (1 - t) * (1 - t) * window[0] - t * t * window[2]) / (2 * t * (1 - t)), window[2]]
newcurve = [qcurve[0], slide(qcurve[0], qcurve[1], 2 / 3), slide(qcurve[1], qcurve[2], 1 / 3), qcurve[2]]
elif len(window) == 4: # Cubic curve on four nodes
newcurve = cubicfrom4(window)
else: # Stress
product = stress(window)
shortseg = min([dist(window[i], window[i + 1]) for i in range(len(window) - 1)])
# Stop condition: maximum error > 1 / 3 * minimum segment length
if max(product[1]) > 0.33 * shortseg: over = True
else: newcurve = product[0]
if over: # Over by error bound
maybeover = True
if not res: res += curcurve[:1]
res += curcurve[1:]
prevtrail = trail
trail = lead - 1
window = points[trail:lead + 1]
curcurve = [window[0], slide(window[0], window[1], 1 / 3), slide(window[0], window[1], 2 / 3), window[1]]
else: curcurve, maybeover = newcurve, False
if maybeover: # When it has reached the end...
newcurve = stress(points[prevtrail:lead + 1])[0]
res[-3:] = newcurve[1:]
else:
if not res: res += curcurve[:1]
res += curcurve[1:] # If it has reached the end, accept curcurve
# Smoothing
ouro = res.pop() # Removes the final (redundant) node of closed paths. In the end, does not affect open paths.
for t in range(0, len(res), 3):
if t != 0 or z: # If not at beginning or if path is closed
v = res[t - 1] - res[t] # Previous handle
w = res[t + 1] - res[t] # Next handle
try:
angle = dotp(v, w) / dist(v) / dist(w)
if angle <= -0.94: # ~ cos(160 degrees)
# Rotate opposing handles and make a straight line.
theta = (pi - acos(angle)) / 2 # Angle to rotate by
sign = 1 if (dirc(v) > dirc(w)) ^ (abs(dirc(v) - dirc(w)) >= pi) else -1 # Direction to rotate (WTF?)
res[t - 1] = res[t] + spin(v, sign * theta)
res[t + 1] = res[t] + spin(w, -sign * theta)
except ZeroDivisionError:
pout("Path has only one point left. Cannot continue")
res.append(ouro)
# Formatting and final output
out = "M " + str(res[0])
for c in range(1, len(res), 3): out += " ".join([" C", str(res[c]), str(res[c + 1]), str(res[c + 2])])
if z: out += " Z "
return out
'''Helper functions and classes below'''
# Node object as a helper to simplify code. Calling it point would be SO cliched.
class node:
def __init__(self, x = None, y = None):
if y != None: self.x, self.y = float(x), float(y)
elif type(x) == list or type(x) == tuple: self.x, self.y = float(x[0]), float(x[1])
else: self.x, self.y = 0.0, 0.0
def __str__(self): return str(self.x) + " " + str(self.y)
def __repr__(self): return str(self)
def __add__(self, pode): return node(self.x + pode.x, self.y + pode.y) # Vector addition
def __sub__(self, pode): return node(self.x - pode.x, self.y - pode.y) # and subtraction
def __neg__(self): return node(-self.x, -self.y)
def __mul__(self, scal): # Multiplication by a scalar
if type(scal) == int or type(scal) == float: return node(self.x * scal, self.y * scal)
else: return node(self.x * scal.x - self.y * scal.y, self.y * scal.x + self.x * scal.y) # Fallback does complex multiplication
def __rmul__(self, scal): return self * scal
def __truediv__(self, scal): # Division by a scalar
if type(scal) == int or type(scal) == float: return node(self.x / scal, self.y / scal)
else:
n = scal.x * scal.x + scal.y * scal.y
return node(self.x * scal.x + self.y * scal.y, self.y * scal.x - self.x * scal.y) / n # Fallback does complex division
# Operations on nodes
def dist(n0, n1 = None): return hypot(n1.y - n0.y, n1.x - n0.x) if n1 else hypot(n0.y, n0.x) # For these two functions
def dirc(n0, n1 = None): return atan2(n1.y - n0.y, n1.x - n0.x) if n1 else atan2(n0.y, n0.x) # n0 is the origin if n1 is present
def slide(n0, n1, t): return n0 + t * (n1 - n0)
def dotp(n0, n1): return n0.x * n1.x + n0.y * n1.y
# Operation on vectors: rotation. Positive theta means counterclockwise rotation.
def spin(v, theta): return node(v.x * cos(theta) - v.y * sin(theta), v.x * sin(theta) + v.y * cos(theta))
# Wrapper function for node curves to mesh with bezierpointatt
def curveat(curve, t): return node(bezierpointatt(((node.x, node.y) for node in curve), t))
# This function takes in a list of nodes and returns
# a list of numbers between 0 and 1 corresponding to the relative positions
# of said nodes (assuming consecutive nodes are linked by straight lines).
# The first item is always 0.0 and the last one 1.0.
def chords(nodes):
lengths = [dist(nodes[i + 1], nodes[i]) for i in range(len(nodes) - 1)]
ratios = [0.0] + [sum(lengths[:i + 1]) / sum(lengths) for i in range(len(lengths))]
ratios[-1] = 1.0 # Just in case...
return ratios
# Takes a list of four nodes and generates a curve passing through all based on chords().
# If lm and mu (the two params for the middle nodes) are not given they are calculated.
def cubicfrom4(nodes, p = None, q = None):
if p == None or q == None:
store = chords(nodes)
lm, mu = store[1], store[2] # First one is short for lambda
else: lm, mu = p, q
a = 3 * (1 - lm) * (1 - lm) * lm
b = 3 * (1 - lm) * lm * lm
c = 3 * (1 - mu) * (1 - mu) * mu
d = 3 * (1 - mu) * mu * mu
x = nodes[1] - (1 - lm) ** 3 * nodes[0] - lm ** 3 * nodes[3]
y = nodes[2] - (1 - mu) ** 3 * nodes[0] - mu ** 3 * nodes[3]
det = a * d - b * c
if not det: pout("Singular matrix!")
l, m = (d * x - b * y) / det, (a * y - c * x) / det
return [nodes[0], l, m, nodes[3]]
# Stress theory: takes a list of five or more nodes and stresses a curve to fit
def stress(string):
# Make an initial guess considering the end nodes together with the 2nd/2nd last, 3rd/3rd last, ... nodes.
# This is much faster than considering all sets of two interior nodes.
callipers, seeds = chords(string), []
middle = len(string) // 2
for i in range(1, middle):
seeds.append(cubicfrom4([string[0], string[i], string[-i - 1], string[-1]], callipers[i], callipers[-i - 1]))
a, b = node(), node()
for i in range(len(seeds)):
a += seeds[i][1]
b += seeds[i][2]
curve = [string[0], a / len(seeds), b / len(seeds), string[-1]]
# Refine by projection and handle shifting
for i in range(5):
for j in range(middle - 1, 0, -1):
delta1, delta2 = project(curve, string[j]), project(curve, string[-j - 1])
curve[1] += 2.5 * delta1
curve[2] += 2.5 * delta2
errors = [dist(project(curve, k)) for k in string]
return curve, errors
# Projection of node onto cubic curve based on public domain code by Mike "Pomax" Kamermans
# https://pomax.github.io/bezierinfo/#projections
def project(curve, node):
samples = 200
lookup = [dist(curveat(curve, i / samples), node) for i in range(samples + 1)]
mindist = min(lookup)
t = lookup.index(mindist) / samples
width = 1 / samples # Width of search interval
while width > 1.0e-5:
left = dist(curveat(curve, max(t - width, 0)), node)
right = dist(curveat(curve, min(t + width, 1)), node)
if t == 0.0: left = mindist + 1
if t == 1.0: right = mindist + 1
if left < mindist or right < mindist:
mindist = min(left, right)
t = max(t - width, 0.0) if left < right else min(t + width, 1.0)
else: width /= 2
projection = curveat(curve, t)
return node - projection
if __name__ == '__main__':
FlevoBezier().run()