This commit is contained in:
Sohel
2024-11-20 17:17:14 +01:00
parent aa14c170c8
commit 09b4990d6d
5404 changed files with 1184888 additions and 3 deletions

View File

@@ -0,0 +1,2 @@
TOLERANCES = {"approximation": 10 ** -2, "input": 10 ** -3, "operation": 10**-6}
UNITS = {"mm", "in"}

View File

@@ -0,0 +1,3 @@
"""The compiler sub-module transforms geometric Curves into CAM machine code."""
from svg_to_gcode.compiler._compiler import Compiler

View File

@@ -0,0 +1,140 @@
import typing
import warnings
from svg_to_gcode.compiler.interfaces import Interface
from svg_to_gcode.geometry import Curve, Line
from svg_to_gcode.geometry import LineSegmentChain
from svg_to_gcode import UNITS, TOLERANCES
class Compiler:
"""
The Compiler class handles the process of drawing geometric objects using interface commands and assembling
the resulting numerical control code.
"""
def __init__(self, interface_class: typing.Type[Interface], movement_speed, cutting_speed, pass_depth,
dwell_time=0, unit=None, custom_header=None, custom_footer=None):
"""
:param interface_class: Specify which interface to use. The ost common is the gcode interface.
:param movement_speed: the speed at which to move the tool when moving. (units are determined by the printer)
:param cutting_speed: the speed at which to move the tool when cutting. (units are determined by the printer)
:param pass_depth: . AKA, the depth your laser cuts in a pass.
:param dwell_time: the number of ms the tool should wait before moving to another cut. Useful for pen plotters.
:param unit: specify a unit to the machine
:param custom_header: A list of commands to be executed before all generated commands. Default is [laser_off,]
:param custom_footer: A list of commands to be executed after all generated commands. Default is [laser_off,]
"""
self.interface = interface_class()
self.movement_speed = movement_speed
self.cutting_speed = cutting_speed
self.pass_depth = abs(pass_depth)
self.dwell_time = dwell_time
if (unit is not None) and (unit not in UNITS):
raise ValueError(f"Unknown unit {unit}. Please specify one of the following: {UNITS}")
self.unit = unit
if custom_header is None:
custom_header = [self.interface.laser_off()]
if custom_footer is None:
custom_footer = [self.interface.laser_off()]
self.header = [self.interface.set_absolute_coordinates(),
self.interface.set_movement_speed(self.movement_speed)] + custom_header
self.footer = custom_footer
self.body = []
def compile(self, passes=1):
"""
Assembles the code in the header, body and footer, saving it to a file.
:param passes: the number of passes that should be made. Every pass the machine moves_down (z-axis) by
self.pass_depth and self.body is repeated.
:return returns the assembled code. self.header + [self.body, -self.pass_depth] * passes + self.footer
"""
if len(self.body) == 0:
warnings.warn("Compile with an empty body (no curves). Is this intentional?")
gcode = []
gcode.extend(self.header)
gcode.append(self.interface.set_unit(self.unit))
for i in range(passes):
gcode.extend(self.body)
if i < passes - 1: # If it isn't the last pass, turn off the laser and move down
gcode.append(self.interface.laser_off())
if self.pass_depth > 0:
gcode.append(self.interface.set_relative_coordinates())
gcode.append(self.interface.linear_move(z=-self.pass_depth))
gcode.append(self.interface.set_absolute_coordinates())
gcode.extend(self.footer)
gcode = filter(lambda command: len(command) > 0, gcode)
return '\n'.join(gcode)
def compile_to_file(self, file_name: str, passes=1):
"""
A wrapper for the self.compile method. Assembles the code in the header, body and footer, saving it to a file.
:param file_name: the path to save the file.
:param passes: the number of passes that should be made. Every pass the machine moves_down (z-axis) by
self.pass_depth and self.body is repeated.
"""
with open(file_name, 'w') as file:
file.write(self.compile(passes=passes))
def append_line_chain(self, line_chain: LineSegmentChain):
"""
Draws a LineSegmentChain by calling interface.linear_move() for each segment. The resulting code is appended to
self.body
"""
if line_chain.chain_size() == 0:
warnings.warn("Attempted to parse empty LineChain")
return []
code = []
start = line_chain.get(0).start
# Don't dwell and turn off laser if the new start is at the current position
if self.interface.position is None or abs(self.interface.position - start) > TOLERANCES["operation"]:
code = [self.interface.laser_off(), self.interface.set_movement_speed(self.movement_speed),
self.interface.linear_move(start.x, start.y), self.interface.set_movement_speed(self.cutting_speed),
self.interface.set_laser_power(1)]
if self.dwell_time > 0:
code = [self.interface.dwell(self.dwell_time)] + code
for line in line_chain:
code.append(self.interface.linear_move(line.end.x, line.end.y))
self.body.extend(code)
def append_curves(self, curves: [typing.Type[Curve]]):
"""
Draws curves by approximating them as line segments and calling self.append_line_chain(). The resulting code is
appended to self.body
"""
for curve in curves:
line_chain = LineSegmentChain()
approximation = LineSegmentChain.line_segment_approximation(curve)
line_chain.extend(approximation)
self.append_line_chain(line_chain)

View File

@@ -0,0 +1,3 @@
from svg_to_gcode.compiler.interfaces._abstract_interface import Interface
from svg_to_gcode.compiler.interfaces._gcode import Gcode
from svg_to_gcode.compiler.interfaces._fan_controlled_gcode import FanControlledGcode

View File

@@ -0,0 +1,106 @@
class Interface:
"""
Classes which inherit from the abstract Interface class provide a consistent interface_class for the gcode parser.
The abstract methods below are necessary for the gcode parser to function. Some child classes may choose to also
implement additional methods like specify_unit and home_axis to provide additional functionality to the parser.
:param self.position stores the current tool position in 2d
"""
# Todo convert to abc class
# Todo add requirement self.position
def set_movement_speed(self, speed) -> str:
"""
Changes the speed at which the tool moves.
:return: Appropriate command.
"""
raise NotImplementedError("Interface class must implement the set_speed command")
def linear_move(self, x=None, y=None, z=None) -> str:
"""
Moves the tool in a straight line.
:return: Appropriate command.
"""
raise NotImplementedError("Interface class must implement the linear_move command")
def laser_off(self) -> str:
"""
Powers off the laser beam.
:return: Appropriate command.
"""
raise NotImplementedError("Interface class must implement the laser_off command")
def set_laser_power(self, power) -> str:
"""
If the target machine supports pwm, change the laser power. Regardless of pwm support, powers on the laser beam
for values of power > 0.
:param power: Defines the power level of the laser. Valid values range between 0 and 1.
:return: Appropriate command.
"""
raise NotImplementedError("Interface class must implement the laser_power command")
def set_absolute_coordinates(self) -> str:
"""
Make the coordinate space absolute. ie. move relative to origin not current position.
return '' if the target of the interface only supports absolute space. If the target only supports
relative coordinate space, this command should return '' and the child class must transform all future inputs from
absolute positions to relative positions until set_relative_coordinates is called.
:return: Appropriate command.
"""
raise NotImplementedError("Interface class must implement the set_absolute_coordinates command")
def set_relative_coordinates(self) -> str:
"""
Make the coordinate space relative. ie. move relative to current position not origin.
return '' if the target of the interface only supports relative space. If the target only supports
absolute coordinate space, this command should return '' and the child class must transform all future inputs from
relative positions to absolute positions until set_absolute_coordinates is called.
:return: Appropriate command.
"""
raise NotImplementedError("Interface class must implement the set_relative_coordinates command")
# Optional commands #
def dwell(self, milliseconds) -> str:
"""
Optional method, if implemented dwells for a determined number of milliseconds before moving to the next command.
:return: Appropriate command.
"""
pass
def set_origin_at_position(self) -> str:
"""
Optional method, if implemented translates coordinate space such that the current position is the new origin.
If the target of the interface does not implement this command, return '' and the child class must translate all
input positions to the new coordinate space.
:return: Appropriate command.
"""
pass
def set_unit(self, unit):
"""
Optional method, if implemented Specifies the unit of measurement.
:return: Appropriate command. If not implemented return ''.
"""
pass
def home_axes(self):
"""
Optional method, if implemented homes all axes.
:return: Appropriate command. If not implemented return ''.
"""
pass

View File

@@ -0,0 +1,21 @@
from svg_to_gcode.compiler.interfaces import Gcode
from svg_to_gcode import formulas
class FanControlledGcode(Gcode):
def laser_off(self):
if self._current_power is None or self._current_power > 0:
self._current_power = 0
return f"M107;"
return ''
def set_laser_power(self, power):
self._current_power = power
if power < 0 or power > 1:
raise ValueError(f"{power} is out of bounds. Laser power must be given between 0 and 1. "
f"The interface will scale it correctly.")
return f"M106 S{formulas.linear_map(0, 255, power)};"

View File

@@ -0,0 +1,95 @@
import warnings
import math
from svg_to_gcode import formulas
from svg_to_gcode.compiler.interfaces import Interface
from svg_to_gcode.geometry import Vector
from svg_to_gcode import TOLERANCES
verbose = False
class Gcode(Interface):
def __init__(self):
self.position = None
self._next_speed = None
self._current_speed = None
# Round outputs to the same number of significant figures as the operational tolerance.
self.precision = abs(round(math.log(TOLERANCES["operation"], 10)))
def set_movement_speed(self, speed):
self._next_speed = speed
return ''
def linear_move(self, x=None, y=None, z=None):
if self._next_speed is None:
raise ValueError("Undefined movement speed. Call set_movement_speed before executing movement commands.")
# Don't do anything if linear move was called without passing a value.
if x is None and y is None and z is None:
warnings.warn("linear_move command invoked without arguments.")
return ''
# Todo, investigate G0 command and replace movement speeds with G1 (normal speed) and G0 (fast move)
command = "G1"
if self._current_speed != self._next_speed:
self._current_speed = self._next_speed
command += f" F{self._current_speed}"
# Move if not 0 and not None
command += f" X{x:.{self.precision}f}" if x is not None else ''
command += f" Y{y:.{self.precision}f}" if y is not None else ''
command += f" Z{z:.{self.precision}f}" if z is not None else ''
if self.position is not None or (x is not None and y is not None):
if x is None:
x = self.position.x
if y is None:
y = self.position.y
self.position = Vector(x, y)
if verbose:
print(f"Move to {x}, {y}, {z}")
return command + ';'
def laser_off(self):
return f"M5;"
def set_laser_power(self, power):
if power < 0 or power > 1:
raise ValueError(f"{power} is out of bounds. Laser power must be given between 0 and 1. "
f"The interface will scale it correctly.")
return f"M3 S{formulas.linear_map(0, 255, power)};"
def set_absolute_coordinates(self):
return "G90;"
def set_relative_coordinates(self):
return "G91;"
def dwell(self, milliseconds):
return f"G4 P{milliseconds}"
def set_origin_at_position(self):
self.position = Vector(0, 0)
return "G92 X0 Y0 Z0;"
def set_unit(self, unit):
if unit == "mm":
return "G21;"
if unit == "in":
return "G20;"
return ''
def home_axes(self):
return "G28;"

View File

@@ -0,0 +1,148 @@
"""
This script contains handy mathematical equations.
It's used to limit code repetition and abstract complicated math functions.
"""
import math
from svg_to_gcode.geometry import Vector, RotationMatrix
from svg_to_gcode import TOLERANCES
def tolerance_constrain(value, maximum, minimum, tolerance=TOLERANCES["operation"]):
"""
Constrain a value between if it surpasses a limit and is within operational tolerance of the limit. Else return the
value. Useful if you want to correct for flatting point errors but still want to raise an exception if the value is
out-of-bounds for a different reason.
"""
if value > maximum and value-maximum < tolerance:
return maximum
if value < minimum and minimum-value < tolerance:
return minimum
return value
def line_slope(p1, p2):
"""Calculate the slope of the line p1p2"""
x1, y1 = p1.x, p1.y
x2, y2 = p2.x, p2.y
if x1 == x2:
return 1
return (y1 - y2) / (x1 - x2)
def line_offset(p1, p2):
"""Calculate the offset of the line p1p2 from the origin"""
x1, y1 = p1.x, p1.y
return y1 - line_slope(p1, p2) * x1
def line_intersect(p1, c1, p2, c2):
"""Find point of intersection between line p1c2 and p2c2"""
p1_c, c1_c, p2_c, c2_c = p1.conjugate(), c1.conjugate(), p2.conjugate(), c2.conjugate()
return (((c1_c - p1_c) * p1 - (c1 - p1) * p1_c) * (c2 - p2) - ((c2_c - p2_c) * p2 - (c2 - p2) * p2_c) * (
c1 - p1)) / ((c2 - p2) * (c1_c - p1_c) - (c1 - p1) * (c2_c - p2_c))
def is_on_mid_perpendicular(z, a, b):
"""Check if a point z is on the line which is perpendicular to ab and passes through the segment's midpoint"""
return ((2 * z - (a + b)) / (a - b)).x == 0
def tangent_arc_center(c, p, g):
"""Find center of circular arc which passes through p and g, and is tangent to the line pc"""
c_c, p_c, g_c = c.conjugate(), p.conjugate(), g.conjugate()
return (c * g * (p_c - g_c) + p * (g * (-2 * p_c + c_c + g_c) + (p_c - c_c) * p)
) / (g * (-p_c + c_c) + c * (p_c - g_c) + (-c_c + g_c) * p)
def linear_map(min, max, t):
"""Linear map from t∈[0, 1] --> t'∈[min, max]"""
return (max - min) * t + min
def inv_linear_map(min, max, t_p):
"""Linear map from t'∈[min, max] --> t∈[0, 1]"""
return (t_p - min)/(max - min)
def angle_between_vectors(v1, v2):
"""Compute angle between two vectors v1, v2"""
cos_angle = Vector.dot_product(v1, v2) / (abs(v1) * abs(v2))
cos_angle = tolerance_constrain(cos_angle, 1, -1)
angle = math.acos(cos_angle)
angle *= 1 if v1.x * v2.y - v1.y * v2.x > 0 else -1
return angle
def center_to_endpoint_parameterization(center, radii, rotation, start_angle, sweep_angle):
rotation_matrix = RotationMatrix(rotation)
start = rotation_matrix * Vector(radii.x * math.cos(start_angle), radii.y * math.sin(start_angle)) + center
end_angle = start_angle + sweep_angle
end = rotation_matrix * Vector(radii.x * math.cos(end_angle), radii.y * math.sin(end_angle)) + center
large_arc_flag = 1 if abs(sweep_angle) > math.pi else 0
sweep_flag = 1 if sweep_angle > 0 else 0
return start, end, large_arc_flag, sweep_flag
def endpoint_to_center_parameterization(start, end, radii, rotation_rad, large_arc_flag, sweep_flag):
# Find and select one of the two possible eclipse centers by undoing the rotation (to simplify the math) and
# then re-applying it.
rotated_primed_values = (start - end) / 2 # Find the primed_values of the start and the end points.
primed_values = RotationMatrix(rotation_rad, True) * rotated_primed_values
px, py = primed_values.x, primed_values.y
# Correct out-of-range radii
rx = abs(radii.x)
ry = abs(radii.y)
delta = px ** 2 / rx ** 2 + py ** 2 / ry ** 2
if delta > 1:
rx *= math.sqrt(delta)
ry *= math.sqrt(delta)
if math.sqrt(delta) > 1:
center = Vector(0, 0)
else:
radicant = ((rx * ry) ** 2 - (rx * py) ** 2 - (ry * px) ** 2) / ((rx * py) ** 2 + (ry * px) ** 2)
radicant = max(0, radicant)
# Find center using w3.org's formula
center = math.sqrt(radicant) * Vector((rx * py) / ry, - (ry * px) / rx)
center *= -1 if large_arc_flag == sweep_flag else 1 # Select one of the two solutions based on flags
rotated_center = RotationMatrix(rotation_rad) * center + (start + end) / 2 # re-apply the rotation
cx, cy = center.x, center.y
u = Vector((px - cx) / rx, (py - cy) / ry)
v = Vector((-px - cx) / rx, (-py - cy) / ry)
max_angle = 2 * math.pi
start_angle = angle_between_vectors(Vector(1, 0), u)
sweep_angle_unbounded = angle_between_vectors(u, v)
sweep_angle = sweep_angle_unbounded % max_angle
if not sweep_flag and sweep_angle > 0:
sweep_angle -= max_angle
if sweep_flag and sweep_angle < 0:
sweep_angle += max_angle
return Vector(rx, ry), rotated_center, start_angle, sweep_angle

View File

@@ -0,0 +1,23 @@
"""
The geometry sub-module offers a geometric representation of curves.
specific maintenance notes:
- Explicit variable declaration for geometric objects. ** You can't dynamically declare instance variables. **
Refer to the docs for more info https://docs.python.org/3/reference/datamodel.html?highlight=__slots__#slots
- The origin is at the bottom-left. As such, all svg_curves must be transformed from the top-left coordinate system
before generating geometric objects.
"""
from svg_to_gcode.geometry._vector import Vector
from svg_to_gcode.geometry._matrix import Matrix, IdentityMatrix, RotationMatrix
from svg_to_gcode.geometry._abstract_curve import Curve
from svg_to_gcode.geometry._line import Line
from svg_to_gcode.geometry._circular_arc import CircularArc
from svg_to_gcode.geometry._elliptical_arc import EllipticalArc
from svg_to_gcode.geometry._quadratic_bazier import QuadraticBezier
from svg_to_gcode.geometry._cubic_bazier import CubicBazier
from svg_to_gcode.geometry._abstract_chain import Chain
from svg_to_gcode.geometry._line_segment_chain import LineSegmentChain
from svg_to_gcode.geometry._smooth_arc_chain import SmoothArcChain

View File

@@ -0,0 +1,104 @@
from collections.abc import Iterable
from svg_to_gcode.geometry import Curve
from svg_to_gcode import formulas
class Chain(Curve):
"""
The Chain class is used to store a sequence of consecutive curves. When considered as a whole, Chains can also be
viewed as a single, continuous curve. They inherit from the Curve class and are equipped with the subsequent point()
and derivative() methods.
"""
__slots__ = '_curves'
def __init__(self, curves=None):
self._curves = []
if curves is not None:
self.extend(curves)
def __iter__(self):
yield from self._curves
def length(self):
"""
Return the geometric length of the chain.
The __len__ magic method wasn't overridden to avoid ambiguity between total length and chain size.
"""
return sum([curve.length() for curve in self._curves])
def chain_size(self):
"""
Return the number of curves in the chain.
The __len__ magic method wasn't overridden to avoid ambiguity between total length and chain size.
"""
return len(self._curves)
def get(self, index: int) -> Curve:
"""Return a curve at a given index"""
return self._curves[index]
def append(self, new_curve: Curve):
"""Append a new curve to the chain"""
raise NotImplementedError("All chains must implement an append command")
def extend(self, new_curves: Iterable):
"""Extend the Chain with an iterable"""
for new_curve in new_curves:
self.append(new_curve)
def merge(self, chain: "Chain"):
"""
Merge the Chain instance with another Chain.
Equivalent to self.extend() but includes additional checks.
"""
if not chain._curves:
return
if self._curves:
assert self.append(chain._curves[0])
self._curves.extend(chain._curves[1:])
def remove_from_first(self, number_of_curves: int):
"""Remove n curves starting from the first"""
for i in range(number_of_curves):
self._curves.pop(0)
def remove_from_last(self, number_of_curves: int):
"""Remove n curves starting from the last"""
for i in range(len(self._curves) - 1, number_of_curves + 1, -1):
self._curves.pop(-1)
def _get_curve_t(self, t):
lengths = [curve.length() for curve in self._curves]
t_position = t * sum(lengths)
position = 0
for i, length in enumerate(lengths):
position += length
if position > t_position:
break
curve_t = formulas.inv_linear_map(position - length, position, t_position)
return self._curves[i], curve_t
def point(self, t):
if self.chain_size() == 0:
raise ValueError("Chain.point was called before adding any curves to the chain.")
curve, curve_t = self._get_curve_t(t)
return curve.point(curve_t)
def derivative(self, t):
if self.chain_size() == 0:
raise ValueError("Chain.derivative was called before adding any curves to the chain.")
curve, curve_t = self._get_curve_t(t)
return curve.derivative(curve_t)
def sanity_check(self):
pass

View File

@@ -0,0 +1,74 @@
from svg_to_gcode import formulas
from svg_to_gcode.geometry import Vector
class Curve:
"""
The Curve abstract class is the cornerstone of the geometry sub-module. Child classes inherit from it to represent
different types of curves.
:param self.start: the first point of the curve
:type self.start: Vector
:param self.end: the last point of the curve
:type self.end: Vector
"""
__slots__ = 'start', 'end'
def point(self, t: float) -> Vector:
"""
The point method returns a point along the curve.
:param t: t is a number between 0 and 1.
:return: the point at distance t*self.length from self.start. For t=0.5, the point half-way across the curve
would be returned.
"""
raise NotImplementedError("point(self, t) must be implemented")
def derivative(self, t):
"""
The derivative method returns a derivative at a point along the curve.
:param t: t is a number between 0 and 1.
:return: the derivative at self.point(t)
"""
raise NotImplementedError("derivative(self, t) must be implemented")
def sanity_check(self):
"""Verify if that the curve is valid."""
raise NotImplementedError("sanity_check(self) must be implemented")
# A custom print representation is trivial to implement and useful for debugging
def __repr__(self):
raise NotImplementedError("__repr__(self) must be implemented")
@staticmethod
def max_distance(curve1: "Curve", curve2: "Curve", t_range1=(0, 1), t_range2=(0, 1), samples=9):
"""
Return the approximate maximum distance between two Curves for different values of t.
WARNING: should only be used when comparing relatively flat curve segments. If one of the two
curves has very eccentric humps, the tip of the hump may not be sampled.
:param curve1: the first of two curves to be compared.
:param curve2: the second of two curves to be compared.
:param t_range1: (min_t, max_t) the range of t values which should be sampled in the first curve. The default
value of (0, 1) samples the whole curve. A value of (0, 5) would only sample from the first half of the curve.
:param t_range2: (min_t, max_t) the range of t values which should be sampled in the second curve. The default
value of (0, 1) samples the whole curve. A value of (0, 5) would only sample from the first half of the curve.
:param samples: the number of samples which should be taken. Higher values are slower but more accurate.
:return: the approximate maximum distance
"""
maximum_distance = 0
for i in range(samples):
t = (i + 1) / (samples + 1)
t1 = formulas.linear_map(t_range1[0], t_range1[1], t)
t2 = formulas.linear_map(t_range2[0], t_range2[1], t)
distance = abs(curve1.point(t1) - curve2.point(t2))
maximum_distance = distance if distance > maximum_distance else maximum_distance
return maximum_distance

View File

@@ -0,0 +1,72 @@
import math
from svg_to_gcode.geometry import Vector
from svg_to_gcode.geometry import Curve
from svg_to_gcode import formulas
from svg_to_gcode import TOLERANCES
class CircularArc(Curve):
"""The CircularArc class inherits from the abstract Curve class and describes a circular arc."""
__slots__ = 'center', 'radius', 'start_angle', 'end_angle'
# ToDo use different instantiation parameters to be consistent with elliptical arcs
def __init__(self, start: Vector, end: Vector, center: Vector):
self.start = start
self.end = end
self.center = center
self.radius = abs(self.start - self.center)
self.start_angle = self.point_to_angle(self.start)
self.end_angle = self.point_to_angle(self.end)
def __repr__(self):
return f"Arc(start: {self.start}, end: {self.end}, center: {self.center})"
def length(self):
return abs(self.start_angle - self.end_angle) * self.radius
def angle_to_point(self, rad):
at_origin = self.radius * Vector(math.cos(rad), math.sin(rad))
translated = at_origin + self.center
return translated
def point_to_angle(self, point: Vector):
translated = (point - self.center)/self.radius # translate the point onto the unit circle
return math.acos(translated.x)
def point(self, t):
angle = formulas.linear_map(self.start_angle, self.end_angle, t)
return self.angle_to_point(angle)
def derivative(self, t):
position = self.point(t)
return (self.center.x - position.x) / (position.y - self.center.y)
def sanity_check(self):
# Assert that the Arc is not a point or a line
try:
assert abs(self.start - self.end) > TOLERANCES["input"]
except AssertionError:
raise ValueError(f"Arc is a point. The start and the end points are equivalent: "
f"|{self.start} - {self.end}| <= {TOLERANCES['input']}")
try:
assert abs(self.start - self.center) > TOLERANCES["input"]
except AssertionError:
raise ValueError(f"Arc is a line. The start and the center points are equivalent, "
f"|{self.start} - {self.center}| <= {TOLERANCES['input']}")
try:
assert abs(self.end - self.center) > TOLERANCES["input"]
except AssertionError:
raise ValueError(f"Arc is a line. The end and the center points are equivalent, "
f"|{self.end} - {self.center}| <= {TOLERANCES['input']}")
# Assert that the center is equidistant from the start and the end
try:
assert abs(abs(self.start - self.center) - abs(self.end - self.center)) < TOLERANCES['input']
except AssertionError:
raise ValueError(f"Center is not equidistant to the start and end points within tolerance, "
f"|{abs(self.start - self.center)} - {abs(self.end - self.center)}| >= {TOLERANCES['input']}")

View File

@@ -0,0 +1,32 @@
from svg_to_gcode.geometry import Vector
from svg_to_gcode.geometry import Curve
class CubicBazier(Curve):
"""The CubicBazier class inherits from the abstract Curve class and describes a cubic bazier."""
__slots__ = 'control1', 'control2'
def __init__(self, start: Vector, end: Vector, control1: Vector, control2: Vector):
self.start = start
self.end = end
self.control1 = control1
self.control2 = control2
def __repr__(self):
return f"CubicBazier(start: {self.start}, end: {self.end}, control1: {self.control1}, control2: {self.control2})"
def point(self, t):
return (1-t)**3 * self.start +\
3 * (1-t)**2 * t * self.control1 +\
3 * (1-t) * t**2 * self.control2 +\
t**3 * self.end
def derivative(self, t):
return 3 * (1-t)**2 * (self.control1 - self.start) +\
6 * (1-t) * t * (self.control2 - self.control1) +\
3 * t**2 * (self.end - self.control2)
def sanity_check(self):
pass

View File

@@ -0,0 +1,58 @@
import math
from svg_to_gcode import formulas
from svg_to_gcode.geometry import Vector, RotationMatrix
from svg_to_gcode.geometry import Curve
class EllipticalArc(Curve):
"""The EllipticalArc class inherits from the abstract Curve class and describes an elliptical arc."""
__slots__ = 'center', 'radii', 'rotation', 'start_angle', 'sweep_angle', 'end_angle', 'transformation'
# ToDo apply transformation beforehand (in Path) for consistency with other geometric objects. If you (the reader)
# know how to easily apply an affine transformation to an ellipse feel free to make a pull request.
def __init__(self, center: Vector, radii: Vector, rotation: float, start_angle: float, sweep_angle: float,
transformation: None):
# Assign and verify arguments
self.center = center
self.radii = radii
self.rotation = rotation
self.start_angle = start_angle
self.sweep_angle = sweep_angle
self.transformation = transformation
# Calculate missing data
self.end_angle = start_angle + sweep_angle
self.start = self.angle_to_point(self.start_angle)
self.end = self.angle_to_point(self.end_angle)
self.sanity_check()
def __repr__(self):
return f"EllipticalArc(start: {self.start}, end: {self.end}, center: {self.center}, radii: {self.radii}," \
f" rotation: {self.rotation}, start_angle: {self.start_angle}, sweep_angle: {self.sweep_angle})"
def point(self, t):
angle = formulas.linear_map(self.start_angle, self.end_angle, t)
return self.angle_to_point(angle)
def angle_to_point(self, angle):
transformed_radii = Vector(self.radii.x * math.cos(angle), self.radii.y * math.sin(angle))
point = RotationMatrix(self.rotation) * transformed_radii + self.center
if self.transformation:
point = self.transformation.apply_affine_transformation(point)
return point
def derivative(self, t):
angle = formulas.linear_map(self.start_angle, self.end_angle, t)
return self.angle_to_derivative(angle)
def angle_to_derivative(self, rad):
return -(self.radii.y / self.radii.x) * math.tan(rad)**-1
def sanity_check(self):
pass

View File

@@ -0,0 +1,32 @@
from svg_to_gcode.geometry import Vector
from svg_to_gcode.geometry import Curve
from svg_to_gcode import formulas
# A line segment
class Line(Curve):
"""The Line class inherits from the abstract Curve class and describes a straight line segment."""
__slots__ = 'slope', 'offset'
def __init__(self, start, end):
self.start = start
self.end = end
self.slope = formulas.line_slope(start, end)
self.offset = formulas.line_offset(start, end)
def __repr__(self):
return f"Line(start:{self.start}, end:{self.end}, slope:{self.slope}, offset:{self.offset})"
def length(self):
return abs(self.start - self.end)
def point(self, t):
x = self.start.x + t * (self.end.x - self.start.x)
y = self.slope * x + self.offset
return Vector(x, y)
def derivative(self, t):
return self.slope

View File

@@ -0,0 +1,88 @@
from svg_to_gcode.geometry import Chain
from svg_to_gcode.geometry import Curve, Line, Vector
from svg_to_gcode import TOLERANCES
class LineSegmentChain(Chain):
"""
The LineSegmentChain class inherits form the abstract Chain class. It represents a series of continuous straight
line-segments.
LineSegmentChains can be instantiated either conventionally or through the static method line_segment_approximation(),
which approximates any Curve with a series of line-segments contained in a new LineSegmentChain instance.
"""
def __repr__(self):
return f"{type(self)}({len(self._curves)} curves: {[line.__repr__() for line in self._curves[:2]]}...)"
def append(self, line2: Line):
if self._curves:
line1 = self._curves[-1]
# Assert continuity
if abs(line1.end - line2.start) > TOLERANCES['input']:
raise ValueError(f"The end of the last line is different from the start of the new line"
f"|{line1.end} - {line2.start}| >= {TOLERANCES['input']}")
# Join lines
line2.start = line1.end
self._curves.append(line2)
@staticmethod
def line_segment_approximation(shape, increment_growth=11 / 10, error_cap=None, error_floor=None)\
-> "LineSegmentChain":
"""
This method approximates any shape using straight line segments.
:param shape: The shape to be approximated.
:param increment_growth: the scale by which line_segments grow and shrink. Must be > 1.
:param error_cap: the maximum acceptable deviation from the curve.
:param error_floor: the maximum minimum deviation from the curve before segment length starts growing again.
:return: A LineSegmentChain which approximates the given shape.
"""
error_cap = TOLERANCES['approximation'] if error_cap is None else error_cap
error_floor = (increment_growth - 1) * error_cap if error_floor is None else error_floor
if error_cap <= 0:
raise ValueError(f"This algorithm is approximate. error_cap must be a non-zero positive float. Not {error_cap}")
if increment_growth <= 1:
raise ValueError(f"increment_growth must be > 1. Not {increment_growth}")
lines = LineSegmentChain()
if isinstance(shape, Line):
lines.append(shape)
return lines
t = 0
line_start = shape.start
increment = 5
while t < 1:
new_t = t + increment
if new_t > 1:
new_t = 1
line_end = shape.point(new_t)
line = Line(line_start, line_end)
distance = Curve.max_distance(shape, line, t_range1=(t, new_t))
# If the error is too high, reduce increment and restart cycle
if distance > error_cap:
increment /= increment_growth
continue
# If the error is very low, increase increment but DO NOT restart cycle.
if distance < error_floor:
increment *= increment_growth
lines.append(line)
line_start = line_end
t = new_t
return lines

View File

@@ -0,0 +1,82 @@
import math
from svg_to_gcode.geometry import Vector
class Matrix:
"""The Matrix class represents matrices. It's mostly used for applying linear transformations to vectors."""
__slots__ = 'number_of_rows', 'number_of_columns', 'matrix_list'
def __init__(self, matrix_list):
"""
:param matrix_list: the matrix represented as a list of rows.
:type matrix_list: list[list]
"""
self.number_of_rows = len(matrix_list)
self.number_of_columns = len(matrix_list[0])
if not all([len(row) == self.number_of_columns for row in matrix_list]):
raise ValueError("Not a matrix. Rows in matrix_list have different lengths.")
if not all([all([isinstance(value, float) or isinstance(value, int) for value in row]) for row in matrix_list]):
raise ValueError("Not a matrix. matrix_list contains non numeric values.")
self.matrix_list = matrix_list
def __repr__(self):
matrix_str = "\n ".join([str(row) for row in self])
return f"Matrix({matrix_str})"
def __iter__(self):
yield from self.matrix_list
def __getitem__(self, index: int):
return self.matrix_list[index]
def __mul__(self, other):
if isinstance(other, Vector):
return self.multiply_vector(other)
if isinstance(other, Matrix):
return self.multiply_matrix(other)
raise TypeError(f"can't multiply matrix by type '{type(other)}'")
def multiply_vector(self, other_vector: Vector):
if self.number_of_columns != 2:
raise ValueError(f"can't multiply matrix with 2D vector. The matrix must have 2 columns, not "
f"{self.number_of_columns}")
x = sum([self[0][k] * other_vector[k] for k in range(self.number_of_columns)])
y = sum([self[1][k] * other_vector[k] for k in range(self.number_of_columns)])
return Vector(x, y)
def multiply_matrix(self, other_matrix: "Matrix"):
if self.number_of_columns != other_matrix.number_of_rows:
raise ValueError(f"can't multiply matrices. The first matrix must have the same number of columns as the "
f"second has rows. {self.number_of_columns}!={other_matrix.number_of_rows}")
matrix_list = [[
sum([self[i][k] * other_matrix[k][j] for k in range(self.number_of_columns)])
for j in range(other_matrix.number_of_columns)]
for i in range(self.number_of_rows)]
return Matrix(matrix_list)
class IdentityMatrix(Matrix):
def __init__(self, size):
matrix_list = [[int(i == j) for j in range(size)] for i in range(size)]
super().__init__(matrix_list)
class RotationMatrix(Matrix):
def __init__(self, angle, inverse=False):
if not inverse:
matrix_list = [[math.cos(angle), -math.sin(angle)],
[math.sin(angle), math.cos(angle)]]
else:
matrix_list = [[math.cos(angle), math.sin(angle)],
[-math.sin(angle), math.cos(angle)]]
super().__init__(matrix_list)

View File

@@ -0,0 +1,29 @@
from svg_to_gcode.geometry import Vector
from svg_to_gcode.geometry import Curve
class QuadraticBezier(Curve):
"""The QuadraticBezier class inherits from the abstract Curve class and describes a quadratic bezier."""
__slots__ = 'control'
def __init__(self, start: Vector, end: Vector, control: Vector):
self.start = start
self.end = end
self.control = control
self.sanity_check()
def __repr__(self):
return f"QuadraticBezier(start: {self.start}, end: {self.end}, control: {self.control})"
def point(self, t):
return self.control + ((1 - t)**2) * (self.start - self.control) + (t**2) * (self.end - self.control)
def derivative(self, t):
return 2 * (1 - t) * (self.control - self.start) + 2 * t * (self.end - self.control)
def sanity_check(self):
# ToDo verify if self.start == self.end forms a valid curve under the svg standard
pass

View File

@@ -0,0 +1,67 @@
"""
This is an unfinished class which should not be committed yet.
"""
from svg_to_gcode.geometry import Chain
from svg_to_gcode.geometry import CircularArc
from svg_to_gcode import TOLERANCES, formulas
class SmoothArcChain(Chain):
def __repr__(self):
return f"SmoothArcs({[arc.__repr__() for arc in self._curves]})"
def append(self, arc2: CircularArc):
if self._curves:
arc1 = self._curves[-1]
# Assert continuity
try:
assert abs(arc1.end - arc2.start) < TOLERANCES['input']
except AssertionError:
raise ValueError(f"The end of the last arc is different from the start of the new arc, "
f"|{arc1.end} - {arc2.start}| >= {TOLERANCES['input']}")
try:
assert abs(arc1.derivative(arc1.end) - arc2.derivative(arc2.start)) < TOLERANCES['input']
except AssertionError:
raise ValueError(f"The last arc and the new arc form a discontinues curve, "
f"|{arc1.derivative(1)} - {arc2.derivative(0)}| >= {TOLERANCES['input']}")
# Join arcs
arc2.start = arc1.end
self._curves.append(arc2)
@staticmethod
def cubic_bazier_to_arcs(bazier, _arcs=None):
smooth_arcs = _arcs if _arcs else SmoothArcChain()
start, control1, control2, end = bazier.start, bazier.control1, bazier.control2, bazier.end
tangent_intersect = formulas.line_intersect(start, control1, end, control2)
# print("tangent_intersect:", tangent_intersect)
start_length = abs(start - tangent_intersect)
end_length = abs(end - tangent_intersect)
base_length = abs(start - end)
# print("start_length:", start_length, "end_length:", end_length, "base_length:", base_length)
incenter_point = (start_length * end + end_length * start + base_length * tangent_intersect) / \
(start_length + end_length + base_length)
# print("incenter:", incenter_point)
center1 = formulas.tangent_arc_center(control1, start, incenter_point)
center2 = formulas.tangent_arc_center(control2, end, incenter_point)
# print("centers:", center1, center2)
smooth_arcs.append(CircularArc(start, incenter_point, center1))
smooth_arcs.append(CircularArc(incenter_point, end, center2))
return smooth_arcs

View File

@@ -0,0 +1,52 @@
class Vector:
"""The Vector class is a simple representation of a 2D vector."""
__slots__ = 'x', 'y'
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other):
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, other):
if isinstance(other, Vector):
return Vector.dot_product(self, other)
return Vector.scalar_product(self, other)
__rmul__ = __mul__
def __truediv__(self, other):
if not (isinstance(other, int) or isinstance(other, float)):
raise TypeError(f"""unsupported operand type(s) for /: 'Vector' and {type(other)}""")
return Vector.scalar_product(self, 1/other)
def __abs__(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
def __iter__(self):
yield from (self.x, self.y) # ignore your editor, these parentheses are not redundant
def __getitem__(self, index: int):
return (self.x, self.y)[index]
@staticmethod
def scalar_product(v1: "Vector", n: int):
return Vector(v1.x * n, v1.y * n)
@staticmethod
def dot_product(v1: "Vector", v2: "Vector"):
return v1.x*v2.x + v1.y*v2.y
@staticmethod
def cross_product(v1: "Vector", v2: "Vector"):
return Vector(v1.x * (v2.x + v2.y), v1.y * (v2.x + v2.y))

View File

@@ -0,0 +1,11 @@
"""
The svg_parser sub-module is used to parse svg_files into the geometric form supplied by the geometry sub-module.
specific maintenance notes:
- The svg origin is at the top-left, while the geometry sub-module has it's origin a the bottom-left. As such, all
parser classes must transform_origin input coordinates to the bottom-left coordinate system.
"""
from svg_to_gcode.svg_parser._transformation import Transformation
from svg_to_gcode.svg_parser._path import Path
from svg_to_gcode.svg_parser._parser_methods import parse_file, parse_string, parse_root

View File

@@ -0,0 +1,102 @@
from xml.etree import ElementTree
from typing import List
from svg_to_gcode.svg_parser import Path
from svg_to_gcode.geometry import Curve
NAMESPACES = {'svg': 'http://www.w3.org/2000/svg'}
def _has_style(element: ElementTree.Element, key: str, value: str) -> bool:
"""
Check if an element contains a specific key and value either as an independent attribute or in the style attribute.
"""
return element.get(key) == value or (element.get("style") and f"{key}:{value}" in element.get("style"))
def parse_root(root: ElementTree.Element, canvas_height=None, transform=True, draw_hidden=False, _visible_root=True) \
-> List[Curve]:
"""
Recursively parse an etree root's children into geometric curves.
:param root: The etree element who's children should be recursively parsed. The root will not be drawn.
:param canvas_height: The height of the canvas. By default the height attribute of the root is used. If the root
does not contain the height attribute, it must be either manually specified or transform must be False.
:param transform: Whether or not to transform input coordinates from the svg coordinate system to standard cartesian
system. Depends on canvas_height for calculations.
:param draw_hidden: Whether or not to draw hidden elements based on their display, visibility and opacity attributes.
:param _visible_root: Internally used to specify whether or the root is visible. (Inheritance can be overridden)
:return: A list of geometric curves describing the svg. Use the Compiler sub-module to compile them to gcode.
"""
if canvas_height is None:
height_str = root.get("height")
canvas_height = float(height_str) if height_str.isnumeric() else float(height_str[:-2])
curves = []
if draw_hidden:
# Parse paths
for element in root.iter("{%s}path" % NAMESPACES["svg"]):
path = Path(element.attrib['d'], canvas_height, transform, transform)
curves.extend(path.curves)
else:
# Draw visible elements (Depth-first search)
for element in list(root):
# display cannot be overridden by inheritance. Just skip the element
if _has_style(element, "display", "none"):
continue
# Is the element and it's root not hidden?
visible = _visible_root and not (_has_style(element, "visibility", "hidden")
or _has_style(element, "visibility", "collapse"))
# Override inherited visibility
visible = visible or (_has_style(element, "visibility", "visible"))
transparent = _has_style(element, "opacity", "0")
# If the current element is opaque and visible, draw it
if not transparent and visible:
if element.tag == "{%s}path" % NAMESPACES["svg"]:
path = Path(element.attrib['d'], canvas_height, transform, transform)
curves.extend(path.curves)
# Continue the recursion
curves.extend(parse_root(element, canvas_height, transform, False, visible))
# ToDo implement shapes class
return curves
# Todo deal with viewBoxes
def parse_string(svg_string: str, canvas_height=None, transform=True, draw_hidden=False) -> List[Curve]:
"""
Recursively parse an svg string into geometric curves. (Wrapper for parse_root)
:param svg_string: The etree element who's children should be recursively parsed. The root will not be drawn.
:param canvas_height: The height of the canvas. By default the height attribute of the root is used. If the root
does not contain the height attribute, it must be either manually specified or transform must be False.
:param transform: Whether or not to transform input coordinates from the svg coordinate system to standard cartesian
system. Depends on canvas_height for calculations.
:param draw_hidden: Whether or not to draw hidden elements based on their display, visibility and opacity attributes.
:return: A list of geometric curves describing the svg. Use the Compiler sub-module to compile them to gcode.
"""
root = ElementTree.fromstring(svg_string)
return parse_root(root, canvas_height, transform, draw_hidden)
def parse_file(file_path: str, canvas_height=None, transform=True, draw_hidden=False) -> List[Curve]:
"""
Recursively parse an svg file into geometric curves. (Wrapper for parse_root)
:param file_path: The etree element who's children should be recursively parsed. The root will not be drawn.
:param canvas_height: The height of the canvas. By default the height attribute of the root is used. If the root
does not contain the height attribute, it must be either manually specified or transform must be False.
:param transform: Whether or not to transform input coordinates from the svg coordinate system to standard cartesian
system. Depends on canvas_height for calculations.
:param draw_hidden: Whether or not to draw hidden elements based on their display, visibility and opacity attributes.
:return: A list of geometric curves describing the svg. Use the Compiler sub-module to compile them to gcode.
"""
root = ElementTree.parse(file_path).getroot()
return parse_root(root, canvas_height, transform, draw_hidden)

View File

@@ -0,0 +1,106 @@
from xml.etree import ElementTree
from typing import List
from copy import deepcopy
from svg_to_gcode.svg_parser import Path, Transformation
from svg_to_gcode.geometry import Curve
NAMESPACES = {'svg': 'http://www.w3.org/2000/svg'}
def _has_style(element: ElementTree.Element, key: str, value: str) -> bool:
"""
Check if an element contains a specific key and value either as an independent attribute or in the style attribute.
"""
return element.get(key) == value or (element.get("style") and f"{key}:{value}" in element.get("style"))
# Todo deal with viewBoxes
def parse_root(root: ElementTree.Element, transform_origin=True, canvas_height=None, draw_hidden=False,
visible_root=True, root_transformation=None) -> List[Curve]:
"""
Recursively parse an etree root's children into geometric curves.
:param root: The etree element who's children should be recursively parsed. The root will not be drawn.
:param canvas_height: The height of the canvas. By default the height attribute of the root is used. If the root
does not contain the height attribute, it must be either manually specified or transform must be False.
:param transform_origin: Whether or not to transform input coordinates from the svg coordinate system to standard
cartesian system. Depends on canvas_height for calculations.
:param draw_hidden: Whether or not to draw hidden elements based on their display, visibility and opacity attributes.
:param visible_root: Specifies whether or the root is visible. (Inheritance can be overridden)
:param root_transformation: Specifies whether the root's transformation. (Transformations are inheritable)
:return: A list of geometric curves describing the svg. Use the Compiler sub-module to compile them to gcode.
"""
if canvas_height is None:
height_str = root.get("height")
canvas_height = float(height_str) if height_str.isnumeric() else float(height_str[:-2])
curves = []
# Draw visible elements (Depth-first search)
for element in list(root):
# display cannot be overridden by inheritance. Just skip the element
display = _has_style(element, "display", "none")
if display or element.tag == "{%s}defs" % NAMESPACES["svg"]:
continue
transformation = deepcopy(root_transformation) if root_transformation else None
transform = element.get('transform')
if transform:
transformation = Transformation() if transformation is None else transformation
transformation.add_transform(transform)
# Is the element and it's root not hidden?
visible = visible_root and not (_has_style(element, "visibility", "hidden")
or _has_style(element, "visibility", "collapse"))
# Override inherited visibility
visible = visible or (_has_style(element, "visibility", "visible"))
# If the current element is opaque and visible, draw it
if draw_hidden or visible:
if element.tag == "{%s}path" % NAMESPACES["svg"]:
path = Path(element.attrib['d'], canvas_height, transform_origin, transformation)
curves.extend(path.curves)
# Continue the recursion
curves.extend(parse_root(element, transform_origin, canvas_height, draw_hidden, visible, transformation))
# ToDo implement shapes class
return curves
def parse_string(svg_string: str, transform_origin=True, canvas_height=None, draw_hidden=False) -> List[Curve]:
"""
Recursively parse an svg string into geometric curves. (Wrapper for parse_root)
:param svg_string: The etree element who's children should be recursively parsed. The root will not be drawn.
:param canvas_height: The height of the canvas. By default the height attribute of the root is used. If the root
does not contain the height attribute, it must be either manually specified or transform_origin must be False.
:param transform_origin: Whether or not to transform input coordinates from the svg coordinate system to standard cartesian
system. Depends on canvas_height for calculations.
:param draw_hidden: Whether or not to draw hidden elements based on their display, visibility and opacity attributes.
:return: A list of geometric curves describing the svg. Use the Compiler sub-module to compile them to gcode.
"""
root = ElementTree.fromstring(svg_string)
return parse_root(root, transform_origin, canvas_height, draw_hidden)
def parse_file(file_path: str, transform_origin=True, canvas_height=None, draw_hidden=False) -> List[Curve]:
"""
Recursively parse an svg file into geometric curves. (Wrapper for parse_root)
:param file_path: The etree element who's children should be recursively parsed. The root will not be drawn.
:param canvas_height: The height of the canvas. By default the height attribute of the root is used. If the root
does not contain the height attribute, it must be either manually specified or transform_origin must be False.
:param transform_origin: Whether or not to transform input coordinates from the svg coordinate system to standard cartesian
system. Depends on canvas_height for calculations.
:param draw_hidden: Whether or not to draw hidden elements based on their display, visibility and opacity attributes.
:return: A list of geometric curves describing the svg. Use the Compiler sub-module to compile them to gcode.
"""
root = ElementTree.parse(file_path).getroot()
return parse_root(root, transform_origin, canvas_height, draw_hidden)

View File

@@ -0,0 +1,322 @@
import math
import warnings
from typing import List
from svg_to_gcode.geometry import Vector
from svg_to_gcode.geometry import Line, EllipticalArc, CubicBazier, QuadraticBezier
from svg_to_gcode.svg_parser import Transformation
from svg_to_gcode import formulas
verbose = False
class Path:
"""The Path class represents a generic svg path."""
command_lengths = {'M': 2, 'm': 2, 'L': 2, 'l': 2, 'H': 1, 'h': 1, 'V': 1, 'v': 1, 'Z': 0, 'z': 0, 'C': 6, 'c': 6,
'Q': 4, 'q': 4, 'S': 4, 's': 4, 'T': 2, 't': 2, 'A': 7, 'a': 7}
__slots__ = "curves", "initial_point", "current_point", "last_control", "canvas_height", "draw_move", \
"transform_origin", "transformation"
def __init__(self, d: str, canvas_height: float, transform_origin=True, transformation=None):
self.canvas_height = canvas_height
self.transform_origin = transform_origin
self.curves = []
self.initial_point = Vector(0, 0) # type: Vector
self.current_point = Vector(0, 0)
self.last_control = None # type: Vector
self.transformation = Transformation()
if self.transform_origin:
self.transformation.add_translation(0, canvas_height)
self.transformation.add_scale(1, -1)
if transformation is not None:
self.transformation.extend(transformation)
try:
self._parse_commands(d)
except Exception as generic_exception:
warnings.warn(f"Terminating path. The following unforeseen exception occurred: {generic_exception}")
def __repr__(self):
return f"Path({self.curves})"
def _parse_commands(self, d: str):
"""Parse svg commands (stored in value of the d key) into geometric curves."""
command_key = '' # A character representing a specific command based on the svg standard
command_arguments = [] # A list containing the arguments for the current command_key
number_str = '' # A buffer used to store numeric characters before conferring them to a number
# Parse each character in d
i = 0
while i < len(d):
character = d[i]
is_numeric = character.isnumeric() or character in ['-', '.', 'e'] # Yes, "-6.2e-4" is a valid float.
is_delimiter = character.isspace() or character in [',']
is_command_key = character in self.command_lengths.keys()
is_final = i == len(d) - 1
# If the current command is complete, however the next command does not specify a new key, assume the next
# command has the same key. This is implemented by inserting the current key before the next command and
# restarting the loop without incrementing i
try:
if command_key and len(command_arguments) == self.command_lengths[command_key] and is_numeric:
duplicate = command_key
# If a moveto is followed by multiple pairs of coordinates, the subsequent pairs are treated as
# implicit lineto commands. https://www.w3.org/TR/SVG2/paths.html#PathDataMovetoCommands
if command_key == 'm':
duplicate = 'l'
if command_key == 'M':
duplicate = 'L'
d = d[:i] + duplicate + d[i:]
continue
except KeyError as key_error:
warnings.warn(f"Unknown command key {command_key}. Skipping curve.")
# If the character is part of a number, keep on composing it
if is_numeric:
number_str += character
# if a negative number follows another number, no delimiter is required.
# implicitly stated decimals like .6 don't require a delimiter. In either case we add a delimiter.
negatives = not is_final and character != 'e' and d[i + 1] == '-'
implicit_decimals = not is_final and d[i + 1] == '.' and '.' in number_str
if negatives or implicit_decimals:
d = d[:i+1] + ',' + d[i+1:]
# If the character is a delimiter or a command key or the last character, complete the number and save it
# as an argument
if is_delimiter or is_command_key or is_final:
if number_str:
# In svg form '-.5' can be written as '-.5'. Python doesn't like that notation.
if number_str[0] == '.':
number_str = '0' + number_str
if number_str[0] == '-' and number_str[1] == '.':
number_str = '-0' + number_str[1:]
command_arguments.append(float(number_str))
number_str = ''
# If it's a command key or the last character, parse the previous (now complete) command and save the letter
# as the new command key
if is_command_key or is_final:
if command_key:
self._add_svg_curve(command_key, command_arguments)
command_key = character
command_arguments.clear()
# If the last character is a command key (only useful for Z), save
if is_command_key and is_final:
self._add_svg_curve(command_key, command_arguments)
i += 1
def _add_svg_curve(self, command_key: str, command_arguments: List[float]):
"""
Offer a representation of a curve using the geometry sub-module.
Based on Mozilla Docs: https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths
Each sub-method must be implemented with the following structure:
def descriptive_name(*command_arguments):
execute calculations and transformations, **do not modify or create any instance variables**
generate curve
modify instance variables
return curve
Alternatively a sub-method may simply call a base command.
:param command_key: a character representing a specific command based on the svg standard
:param command_arguments: A list containing the arguments for the current command_key
"""
# Establish a new initial point and a new current point. (multiple coordinates are parsed as lineto commands)
def absolute_move(x, y):
self.initial_point = Vector(x, y)
self.current_point = Vector(x, y)
return None
def relative_move(dx, dy):
return absolute_move(*(self.current_point + Vector(dx, dy)))
# Draw straight line
def absolute_line(x, y):
start = self.current_point
end = Vector(x, y)
line = Line(self.transformation.apply_affine_transformation(start),
self.transformation.apply_affine_transformation(end))
self.current_point = end
return line
def relative_line(dx, dy):
return absolute_line(*(self.current_point + Vector(dx, dy)))
def absolute_horizontal_line(x):
return absolute_line(x, self.current_point.y)
def relative_horizontal_line(dx):
return absolute_horizontal_line(self.current_point.x + dx)
def absolute_vertical_line(y):
return absolute_line(self.current_point.x, y)
def relative_vertical_line(dy):
return absolute_vertical_line(self.current_point.y + dy)
def close_path():
return absolute_line(*self.initial_point)
# Draw curvy curves
def absolute_cubic_bazier(control1_x, control1_y, control2_x, control2_y, x, y):
trans_start = self.transformation.apply_affine_transformation(self.current_point)
trans_end = self.transformation.apply_affine_transformation(Vector(x, y))
trans_control1 = self.transformation.apply_affine_transformation(Vector(control1_x, control1_y))
trans_control2 = self.transformation.apply_affine_transformation(Vector(control2_x, control2_y))
cubic_bezier = CubicBazier(trans_start, trans_end, trans_control1, trans_control2)
self.last_control = Vector(control2_x, control2_y)
self.current_point = Vector(x, y)
return cubic_bezier
def relative_cubic_bazier(dx1, dy1, dx2, dy2, dx, dy):
return absolute_cubic_bazier(self.current_point.x + dx1, self.current_point.y + dy1,
self.current_point.x + dx2, self.current_point.y + dy2,
self.current_point.x + dx, self.current_point.y + dy)
def absolute_cubic_bezier_extension(x2, y2, x, y):
start = self.current_point
control2 = Vector(x2, y2)
end = Vector(x, y)
if self.last_control:
control1 = 2 * start - self.last_control
bazier = absolute_cubic_bazier(*control1, *control2, *end)
else:
bazier = absolute_quadratic_bazier(*control2, *end)
self.current_point = start
return bazier
def relative_cubic_bazier_extension(dx2, dy2, dx, dy):
return absolute_cubic_bezier_extension(self.current_point.x + dx2, self.current_point.y + dy2,
self.current_point.x + dx, self.current_point.y + dy)
def absolute_quadratic_bazier(control1_x, control1_y, x, y):
trans_end = self.transformation.apply_affine_transformation(self.current_point)
trans_new_end = self.transformation.apply_affine_transformation(Vector(x, y))
trans_control1 = self.transformation.apply_affine_transformation(Vector(control1_x, control1_y))
quadratic_bezier = QuadraticBezier(trans_end, trans_new_end, trans_control1)
self.last_control = Vector(control1_x, control1_y)
self.current_point = Vector(x, y)
return quadratic_bezier
def relative_quadratic_bazier(dx1, dy1, dx, dy):
return absolute_quadratic_bazier(self.current_point.x + dx1, self.current_point.y + dy1,
self.current_point.x + dx, self.current_point.y + dy)
def absolute_quadratic_bazier_extension(x, y):
start = self.current_point
end = Vector(x, y)
if self.last_control:
control = 2 * start - self.last_control
bazier = absolute_quadratic_bazier(*control, *end)
else:
bazier = absolute_quadratic_bazier(*start, *end)
self.current_point = end
return bazier
def relative_quadratic_bazier_extension(dx, dy):
return absolute_quadratic_bazier_extension(self.current_point.x + dx, self.current_point.y + dy)
# Generate EllipticalArc with center notation from svg endpoint notation.
# Based on w3.org implementation notes. https://www.w3.org/TR/SVG2/implnote.html
# Todo transformations aren't applied correctly to elliptical arcs
def absolute_arc(rx, ry, deg_from_horizontal, large_arc_flag, sweep_flag, x, y):
end = Vector(x, y)
start = self.current_point
radii = Vector(rx, ry)
rotation_rad = math.radians(deg_from_horizontal)
if abs(start-end) == 0:
raise ValueError("start and end points can't be equal")
radii, center, start_angle, sweep_angle = formulas.endpoint_to_center_parameterization(
start, end, radii, rotation_rad, large_arc_flag, sweep_flag)
arc = EllipticalArc(center, radii, rotation_rad, start_angle, sweep_angle, transformation=self.transformation)
self.current_point = end
return arc
def relative_arc(rx, ry, deg_from_horizontal, large_arc_flag, sweep_flag, dx, dy):
return absolute_arc(rx, ry, deg_from_horizontal, large_arc_flag, sweep_flag, self.current_point.x + dx, self.current_point.y + dy)
command_methods = {
# Only move end point
'M': absolute_move,
'm': relative_move,
# Draw straight line
'L': absolute_line,
'l': relative_line,
'H': absolute_horizontal_line,
'h': relative_horizontal_line,
'V': absolute_vertical_line,
'v': relative_vertical_line,
'Z': close_path,
'z': close_path,
# Draw bazier curves
'C': absolute_cubic_bazier,
'c': relative_cubic_bazier,
'S': absolute_cubic_bezier_extension,
's': relative_cubic_bazier_extension,
'Q': absolute_quadratic_bazier,
'q': relative_quadratic_bazier,
'T': absolute_quadratic_bazier_extension,
't': relative_quadratic_bazier_extension,
# Draw elliptical arcs
'A': absolute_arc,
'a': relative_arc
}
try:
curve = command_methods[command_key](*command_arguments)
except TypeError as type_error:
warnings.warn(f"Mis-formed input. Skipping command {command_key, command_arguments} because it caused the "
f"following error: \n{type_error}")
except ValueError as value_error:
warnings.warn(f"Impossible geometry. Skipping curve {command_key, command_arguments} because it caused the "
f"following value error:\n{value_error}")
else:
if curve is not None:
self.curves.append(curve)
if verbose:
print(f"{command_key}{tuple(command_arguments)} -> {curve}")

View File

@@ -0,0 +1,155 @@
import math
from copy import deepcopy
from svg_to_gcode.geometry import Vector, Matrix, IdentityMatrix
class Transformation:
"""
The Transformation class handles the parsing and computation behind svg transform attributes.
"""
__slots__ = "translation_matrix", "transformation_record", "command_methods"
def __init__(self):
# Fancy matrix used for affine transformations (translations and linear transformations)
self.translation_matrix = IdentityMatrix(4)
self.transformation_record = []
self.command_methods = {
"matrix": self.add_matrix,
"translate": self.add_translation,
"scale": self.add_scale,
"rotate": self.add_rotation,
"skewX": self.add_skew_x,
"skewY": self.add_skew_y
}
def __repr__(self):
transformations = ", ".join(
[f"{transformation[0]}("f"{', '.join(map(lambda x: str(x), self.transformation_record[0][1]))})"
for transformation in self.transformation_record])
return f"Transformation({transformations})"
def __deepcopy__(self, memodict={}):
copy = Transformation()
copy.translation_matrix = deepcopy(self.translation_matrix)
return copy
def add_transform(self, transform_string: str):
transformations = transform_string.split(')')
for transformation in transformations:
transformation = transformation.strip()
if not transformation or '(' not in transformation:
continue
command, arguments = transformation.split('(')
command = command.replace(',', '')
command = command.strip()
arguments = [float(argument.strip()) for argument in arguments.replace(',', ' ').split()]
command_method = self.command_methods[command]
command_method(*arguments)
# SVG transforms are equivalent to CSS transforms https://www.w3.org/TR/css-transforms-1/#MatrixDefined
def add_matrix(self, a, b, c, d, e, f):
self.transformation_record.append(("matrix", [a, b, c, d, e, f]))
matrix = Matrix([
[a, c, 0, e],
[b, d, 0, f],
[0, 0, 1, 0],
[0, 0, 0, 1]
])
self.translation_matrix *= matrix
def add_translation(self, x: float, y=0.0):
self.transformation_record.append(("translate", [x, y]))
translation_matrix = Matrix([
[1, 0, 0, x],
[0, 1, 0, y],
[0, 0, 1, 0],
[0, 0, 0, 1]
])
self.translation_matrix *= translation_matrix
def add_scale(self, factor: float, factor_y=None):
factor_x = factor
factor_y = factor if factor_y is None else factor_y
self.transformation_record.append(("scale", [factor_x, factor_y]))
scale_matrix = Matrix([
[factor_x, 0, 0, 0],
[0, factor_y, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]
])
self.translation_matrix *= scale_matrix
def add_rotation(self, angle: float):
self.transformation_record.append(("rotate", [angle]))
angle = math.radians(angle)
rotation_matrix = Matrix([
[math.cos(angle), -math.sin(angle), 0, 0],
[math.sin(angle), math.cos(angle), 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]
])
self.translation_matrix *= rotation_matrix
def add_skew_x(self, angle):
self.transformation_record.append(("skewX", [angle]))
angle = math.radians(angle)
skew_matrix = IdentityMatrix(4)
skew_matrix.matrix_list[0][1] = math.tan(angle)
self.translation_matrix *= skew_matrix
def add_skew_y(self, angle):
self.transformation_record.append(("skewY", [angle]))
angle = math.radians(angle)
skew_matrix = IdentityMatrix(4)
skew_matrix.matrix_list[1][0] = math.tan(angle)
self.translation_matrix *= skew_matrix
def extend(self, other: "Transformation"):
self.translation_matrix *= other.translation_matrix
self.transformation_record.extend(other.transformation_record)
def apply_affine_transformation(self, vector: Vector) -> Vector:
"""
Apply the full affine transformation (linear + translation) to a vector. Generally used to transform points.
Eg the center of an ellipse.
"""
vector_4d = Matrix([[vector.x], [vector.y], [1], [1]])
vector_4d = self.translation_matrix * vector_4d
return Vector(vector_4d.matrix_list[0][0], vector_4d.matrix_list[1][0])
def apply_linear_transformation(self, vector: Vector) -> Vector:
"""
Apply the linear component of the affine transformation (no translation) to a vector.
Generally used to transform vector properties. Eg the radii of an ellipse.
"""
a = self.translation_matrix[0][0]
b = self.translation_matrix[1][0]
c = self.translation_matrix[0][1]
d = self.translation_matrix[1][1]
linear_transformation = Matrix([
[a, c],
[b, d]
])
return linear_transformation * vector

View File

@@ -0,0 +1,67 @@
from xml.etree import ElementTree
from svg_to_gcode.geometry import LineSegmentChain, Vector
svg_namespace = 'http://www.w3.org/2000/svg'
def to_svg_path(line_segment_chain: LineSegmentChain, transformation=None, color="black", opacity="1",
stroke_width="0.864583px", draw_arrows=False, arrow_id="arrow-346") -> ElementTree.Element:
"""
A handy debugging function which converts the current line-chain to svg form
:param line_segment_chain: The LineSegmentChain to the converted.
:param transformation: A transformation to apply to every line before converting it.
:param color: The path's color.
:param opacity: The path's opacity.
:param stroke_width: The path's stroke width.
:param draw_arrows: Whether or not to draw arrows at the end of each segment. Requires placing the output of
arrow_defs() in the document.
:param arrow_id: The id of the arrow def. default = arrow-346
"""
start = Vector(line_segment_chain.get(0).start.x, line_segment_chain.get(0).start.y)
if transformation:
start = transformation.apply_affine_transformation(start)
d = f"M{start.x} {start.y}"
for line in line_segment_chain:
end = Vector(line.end.x, line.end.y)
if transformation:
end = transformation.apply_affine_transformation(end)
d += f" L {end.x} {end.y}"
style = f"fill:none;stroke:{color};stroke-opacity:{opacity};stroke-width:{stroke_width};stroke-linecap:butt;stroke-linejoin:miter;"
path = ElementTree.Element("{%s}path" % svg_namespace)
path.set("d", d)
path.set("style", style)
if draw_arrows:
path.set("marker-mid", f"url(#{arrow_id})")
return path
def arrow_defs(arrow_scale=1, arrow_id="arrow-346"):
defs = ElementTree.Element("{%s}defs" % svg_namespace)
marker = ElementTree.Element("{%s}marker" % svg_namespace)
marker.set("id", arrow_id)
marker.set("viewBox", "0 0 10 10")
marker.set("refX", "5")
marker.set("refY", "5")
marker.set("markerWidth", str(arrow_scale))
marker.set("markerHeight", str(arrow_scale))
marker.set("orient", "auto-start-reverse")
defs.append(marker)
arrow = ElementTree.Element("{%s}path" % svg_namespace)
arrow.set("d", "M 5 0 l 10 5 l -10 5 z")
arrow.set("fill", "yellow")
arrow.set("stroke", "black")
arrow.set("stroke-width", "0.1")
marker.append(arrow)
return defs