Skip to content

Instantly share code, notes, and snippets.

@nitori
Last active February 22, 2025 12:33
Show Gist options
  • Save nitori/14c94084f4c83b3fb3e21f24a38414d2 to your computer and use it in GitHub Desktop.
Save nitori/14c94084f4c83b3fb3e21f24a38414d2 to your computer and use it in GitHub Desktop.
generate regular polygons with rounded corners. just experimenting to understand the math
from typing import Self
import math
import io
type Number = int | float
type TVec = tuple[Number, Number]
class Vector:
repr_precision = 6
def __init__(self, x, y=None):
if y is None:
x, y = x
self.x = x
self.y = y
@staticmethod
def _calc(me: 'Vector', other: 'Vector | TVec | Number', op):
if isinstance(other, (int, float)):
return Vector(op(me.x, other), op(me.y, other))
if isinstance(other, tuple) or isinstance(other, Vector):
x, y = other
return Vector(op(me.x, x), op(me.y, y))
return NotImplemented
def normalize(self):
d = self.length()
return Vector(self.x / d, self.y / d)
def length(self) -> float:
return math.sqrt(self.x ** 2 + self.y ** 2)
def __iter__(self):
yield self.x
yield self.y
def __getitem__(self, item) -> Number:
return [self.x, self.y][item]
def __setitem__(self, key, value: Number):
if key == 0:
self.x = value
elif key == 1:
self.y = value
else:
raise IndexError('Index out of range')
def __add__(self, other: Self | TVec | Number):
return self._calc(self, other, lambda a, b: a + b)
def __sub__(self, other: Self | TVec | Number):
return self._calc(self, other, lambda a, b: a - b)
def __mul__(self, other: Self | TVec | Number):
return self._calc(self, other, lambda a, b: a * b)
def __truediv__(self, other: Self | TVec | Number):
return self._calc(self, other, lambda a, b: a / b)
def __divmod__(self, other: Self | TVec | Number):
return self._calc(self, other, lambda a, b: divmod(a, b))
def __floordiv__(self, other: Self | TVec | Number):
return self._calc(self, other, lambda a, b: a // b)
def __mod__(self, other: Self | TVec | Number):
return self._calc(self, other, lambda a, b: a % b)
def __neg__(self):
return self * -1
def __matmul__(self, other: Self | TVec) -> Number:
if isinstance(other, tuple) or isinstance(other, Vector):
x, y = other
return self.x * x + self.y * y
return NotImplemented
def __radd__(self, other: Self | TVec | Number):
return self._calc(self, other, lambda a, b: b + a)
def __rsub__(self, other: Self | TVec | Number):
return self._calc(self, other, lambda a, b: b - a)
def __rmul__(self, other: Self | TVec | Number):
return self._calc(self, other, lambda a, b: b * a)
def __rtruediv__(self, other: Self | TVec | Number):
return self._calc(self, other, lambda a, b: b / a)
def __rdivmod__(self, other: Self | TVec | Number):
return self._calc(self, other, lambda a, b: divmod(b, a))
def __rfloordiv__(self, other: Self | TVec | Number):
return self._calc(self, other, lambda a, b: b // a)
def __rmod__(self, other: Self | TVec | Number):
return self._calc(self, other, lambda a, b: b % a)
def __rmatmul__(self, other: Self | TVec) -> Number:
return self.__matmul__(other)
def __repr__(self):
return f'Vector({self.x:.{self.repr_precision}f}, {self.y:.{self.repr_precision}f})'
def __format__(self, format_spec):
separator = ','
if '|' in format_spec:
format_spec, separator = format_spec.rsplit('|', 1)
return f'{self.x:{format_spec}}{separator}{self.y:{format_spec}}'
class Line:
start: Vector
end: Vector
def __init__(self, start: Vector, end: Vector):
self.start = start
self.end = end
@property
def m(self) -> float | None:
"""
Returns None if the line is vertical.
Easier to check against thatn e.g. math.inf
"""
if self.is_vertical:
return None
# probably not needed:
# if self.is_horizontal:
# return 0
return (self.end.y - self.start.y) / (self.end.x - self.start.x)
@property
def b(self) -> float | None:
if self.m is None:
return None
return self.start.y - self.m * self.start.x
@property
def is_vertical(self) -> bool:
return math.isclose(self.start.x, self.end.x)
@property
def is_horizontal(self) -> bool:
return math.isclose(self.start.y, self.end.y)
def normal(self, p: Vector) -> 'Line':
"""
Return the normal line that goes through point p.
The returned lines end point is the intersection point of the two lines.
"""
if self.is_horizontal:
ipoint = Vector(p.x, self.start.y)
elif self.is_vertical:
ipoint = Vector(self.start.x, p.y)
else:
m = -1 / self.m
b = p.y - m * p.x
x = (b - self.b) / (self.m - m)
y = m * x + b
ipoint = Vector(x, y)
return Line(p, ipoint)
def intersect(self, other: 'Line') -> Vector:
if self.m is not None and other.m is not None and math.isclose(self.m, other.m):
raise ValueError('Lines are parallel')
if self.m is None:
x = self.start.x
y = other.m * x + other.b
return Vector(x, y)
if other.m is None:
x = other.start.x
y = self.m * x + self.b
return Vector(x, y)
x = (other.b - self.b) / (self.m - other.m)
y = self.m * x + self.b
return Vector(x, y)
def vec_at_angle(deg):
return Vector(
math.cos(math.radians(deg)),
math.sin(math.radians(deg))
)
def find_normal_intersection(c: Vector, p1: Vector, p2: Vector):
"""Find the intersection point on the line (p1,p2) of normal that goes through c."""
line = Line(p1, p2)
normal = line.normal(c)
return normal.end
def get_circle_pos(s: Vector, c: Vector, e: Vector, radius: float) -> Vector:
v1 = s - c
v2 = e - c
dot = v1 @ v2
# this part was made by ChatGPT, though I had to ask it quite a bit
# to help me understand other parts as well.
cos_theta = dot / (v1.length() * v2.length())
cos_theta = max(min(cos_theta, 1.0), -1.0)
angle = math.acos(cos_theta)
if angle <= 0 or angle >= math.pi:
raise ValueError('Angle is not in range. What kinda shape you trying to draw?')
distance = radius / math.sin(angle / 2)
return c + distance * (-c)
def get_drawing_data(r, corner_vectors):
corner_triplets: list[tuple[Vector, Vector, Vector]] = zip( # noqa
corner_vectors[-1:] + corner_vectors[:-1],
corner_vectors,
corner_vectors[1:] + corner_vectors[:1],
)
for start, corner, end in corner_triplets:
center = get_circle_pos(start, corner, end, r)
arc_start = find_normal_intersection(center, start, corner)
arc_end = find_normal_intersection(center, end, corner)
yield start, arc_start, center, arc_end, end
def generate_regular_polygon(
sides: int,
size: float | int,
radius: float | int,
) -> str:
if sides < 3:
raise ValueError('You need at least 3 sides for your polygon')
# internally the size is 2, with an offset of -1 (from range -1 to 1, unit circle essentially)
rsize = 2 / size
internal_radius = radius * rsize
corner_vectors = [vec_at_angle(360 / sides * n - 90) for n in range(sides)]
output = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" viewBox="0 0 {size} {size}">',
' <path fill="#000" d="'
]
liftoff = True
for values in get_drawing_data(internal_radius, corner_vectors):
start, arc_start, center, arc_end, end = ((v + 1) / rsize for v in values)
output.append(f' {"M" if liftoff else "L"} {arc_start:.6f}')
output.append(f' A {radius:.6f},{radius:.6f} 0 0 1 {arc_end:.6f}')
liftoff = False
output.append(' Z')
output.append(' "/>')
output.append('</svg>')
return "\n".join(output)
def main():
with open('heptagon.svg', 'w', encoding='utf-8') as f:
svg = generate_regular_polygon(5, 500, 50)
f.write(svg)
if __name__ == '__main__':
main()
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment