Last active
February 22, 2025 12:33
-
-
Save nitori/14c94084f4c83b3fb3e21f24a38414d2 to your computer and use it in GitHub Desktop.
generate regular polygons with rounded corners. just experimenting to understand the math
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment