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,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))