Skip to content

Commit e8445ea

Browse files
author
XorUnison
committed
Initial Commit
Added shoelace method to space_ops.py Added orientation methods to vectorized_mobject.py, uses shoelace Added ArcPolygon, Tiling and Graph to geometry.py
1 parent e5b01cc commit e8445ea

File tree

3 files changed

+249
-5
lines changed

3 files changed

+249
-5
lines changed

manim/mobject/geometry.py

Lines changed: 213 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,7 @@ def position_tip(self, tip, at_start=False):
112112

113113
def reset_endpoints_based_on_tip(self, tip, at_start):
114114
if self.get_length() == 0:
115-
# Zero length, put_start_and_end_on wouldn't
116-
# work
115+
# Zero length, put_start_and_end_on wouldn't work
117116
return self
118117

119118
if at_start:
@@ -295,8 +294,8 @@ def __init__(self, start, end, angle=TAU / 4, radius=None, **kwargs):
295294
sign=2
296295
halfdist=np.linalg.norm(np.array(start) - np.array(end)) / 2
297296
if radius < halfdist:
298-
raise ValueError("""ArcBetweenPoints called with a radius that is
299-
smaller than half the distance between the points.""")
297+
raise ValueError("ArcBetweenPoints called with a radius that is "
298+
"smaller than half the distance between the points.")
300299
arc_height=radius - math.sqrt(radius ** 2 - halfdist ** 2)
301300
angle=math.acos((radius - arc_height) / radius)*sign
302301

@@ -804,6 +803,45 @@ def __init__(self, n=6, **kwargs):
804803
Polygon.__init__(self, *vertices, **kwargs)
805804

806805

806+
class ArcPolygon(VMobject):
807+
"""
808+
The ArcPolygon is what it says, a polygon, but made from arcs.
809+
More versatile than the standard Polygon.
810+
Accepts both Arc and ArcBetweenPoints and is instantiated like this:
811+
ArcPolygon(arc0,arc1,arc2,arcN,**kwargs)
812+
For proper appearance the arcs should be seamlessly connected:
813+
[a,b][b,c][c,a]
814+
If they don't, the gaps will be filled in with straight lines.
815+
816+
ArcPolygon also doubles as a group for the input arcs.
817+
That means these arcs can be drawn and seen separately.
818+
If only the ArcPolygon itself is supposed to be visible,
819+
make the arcs invisible (for example with "stroke_width": 0).
820+
ArcPolygon.arcs can be used get values from these subarcs.
821+
"""
822+
def __init__(self, *arcs, **kwargs):
823+
if not all([isinstance(m, Arc) or
824+
isinstance(m, ArcBetweenPoints) for m in arcs]):
825+
raise Exception("All ArcPolygon submobjects must be of"
826+
"type Arc/ArcBetweenPoints")
827+
VMobject.__init__(self, **kwargs)
828+
# Adding the arcs like this makes arcpolygon double as a group
829+
self.add(*arcs)
830+
# This adding is also needed for this line and ArcPolygon.arcs
831+
# to return the arcs as their values currently are
832+
self.arcs = [*arcs]
833+
for arc1, arc2 in adjacent_pairs(arcs):
834+
self.append_points(arc1.points)
835+
line = Line(arc1.get_end(), arc2.get_start())
836+
len_ratio = line.get_length() / arc1.get_arc_length()
837+
if math.isnan(len_ratio) or math.isinf(len_ratio):
838+
continue
839+
line.insert_n_curves(
840+
int(arc1.get_num_curves() * len_ratio)
841+
)
842+
self.append_points(line.get_points())
843+
844+
807845
class Triangle(RegularPolygon):
808846
def __init__(self, **kwargs):
809847
RegularPolygon.__init__(self, n=3, **kwargs)
@@ -876,3 +914,174 @@ class RoundedRectangle(Rectangle):
876914
def __init__(self, **kwargs):
877915
Rectangle.__init__(self, **kwargs)
878916
self.round_corners(self.corner_radius)
917+
918+
919+
class Tiling(VMobject):
920+
"""
921+
The purpose of this class is to create tilings/tesselations.
922+
Tilings can also be seemlessly transformed into each other.
923+
This requires their ranges to be the same, tiles to be oriented and
924+
rotated the correct way, as well as having a vertex setup that
925+
allows proper transformations.
926+
927+
It's instantiated like this:
928+
Tiling(tile_prototype, xOffset, yOffset, xRange, yRange, **kwargs)
929+
The tile prototype can be any Mobject, a VGroup or a function.
930+
The function format is function(x,y), returning a Mobject/VGroup.
931+
Using groups or functions allows the tiling to contain multiple
932+
different tiles or to simplify the following offset functions.
933+
934+
Next are two nested lists that determine how the tiles are arranged.
935+
The functions are typically Mobject.shift and Mobject.rotate.
936+
Each list has to contain sublists with Function/Value pairs.
937+
Example for a simple shift along the X-Axis:
938+
[[Mobject.shift,[1,0,0]]]
939+
940+
Every move within the tiling applies a full sublist.
941+
Example for a shift with simultaneous rotation:
942+
[[Mobject.shift,[1,0,0],Mobject.rotate,np.pi]]
943+
944+
When multiple sublists are passed, they are applied alternating.
945+
Example for alternating shifting and rotating:
946+
[[Mobject.shift,[1,0,0]],[Mobject.rotate,np.pi]]
947+
948+
Last are two ranges: If both ranges are range(-1,1,1),
949+
that would result in a grid of 9 tiles.
950+
951+
Full example:
952+
Tiling(Square(),
953+
[[Mobject.shift,[2.1,0,0]]],
954+
[[Mobject.shift,[0,2.1,0]]],
955+
range(-1,1),
956+
range(-1,1))
957+
958+
A Tiling can be directly drawn like a VGroup.
959+
Tiling.tile_dictionary[x][y] can be used to access individual tiles,
960+
to color them for example.
961+
"""
962+
def __init__(self, tile_prototype, xOffset, yOffset, xRange, yRange, **kwargs):
963+
VMobject.__init__(self, **kwargs)
964+
# First we add one more to the range,
965+
# so that a -1,1 step 1 range also gives us 3 tiles,
966+
# [-1,0,1] as opposed to 2 [-1,0]
967+
self.xRange=range(xRange.start,xRange.stop+xRange.step,xRange.step)
968+
self.yRange=range(yRange.start,yRange.stop+yRange.step,yRange.step)
969+
970+
# We need the tiles array for a VGroup, which in turn we need
971+
# to draw the tiling and adjust it.
972+
# Trying to draw the tiling directly will not properly work.
973+
self.tile_dictionary={}
974+
self.kwargs = kwargs
975+
for x in self.xRange:
976+
self.tile_dictionary[x]={}
977+
for y in self.yRange:
978+
if callable(tile_prototype):
979+
tile=tile_prototype(x,y).deepcopy()
980+
else:
981+
tile=tile_prototype.deepcopy()
982+
self.transform_tile(x,xOffset,tile)
983+
self.transform_tile(y,yOffset,tile)
984+
self.add(tile)
985+
self.tile_dictionary[x][y]=tile
986+
# TODO: Once the config overhaul is far enough:
987+
# Implement a way to apply kwargs/some dict to all tiles
988+
989+
# This method computes and applies the offsets for the tiles.
990+
# Also multiplies inputs, which requires arrays to be numpy arrays.
991+
def transform_tile(self,position,offset,tile):
992+
# The number of different offsets the current axis has
993+
offsets_nr=len(offset)
994+
for i in range(offsets_nr):
995+
for j in range(int(len(offset[i])/2)):
996+
if position<0:
997+
# Magnitude is calculated as the length of a range.
998+
# The range starts at 0, adjusting for the number
999+
# of different offset functions, stops at the target
1000+
# position, and uses the amount of different
1001+
# offset functions as the step.
1002+
magnitude=len(range(-i,position,-offsets_nr))*-1
1003+
offset[-1-i][0+j*2](tile,magnitude*np.array(offset[-1-i][1+j*2]))
1004+
else:
1005+
magnitude=len(range(i,position,offsets_nr))
1006+
offset[i][0+j*2](tile,magnitude*np.array(offset[i][1+j*2]))
1007+
1008+
1009+
class Graph():
1010+
"""
1011+
This class is for visual representation of graphs for graph theory.
1012+
(Not graphs of functions. Same term but entirely different things.)
1013+
1014+
It's instantiated with a dictionary that represents the graph,
1015+
a configuration dictionary for vertex appearance, and one for edges.
1016+
The configuration dictionaries are optional.
1017+
Graph(graph_dictionary, vertex_config=vc, edge_config=ec)
1018+
1019+
The keys for the graph have to be of type int in ascending order,
1020+
with each number denoting a vertex.
1021+
The values are lists with 3 elements. A list of coordinates,
1022+
a list of connected vertices, and a configuration dictionary.
1023+
The coordinates determine the position of the vertex.
1024+
The list of connected vertices determines between which vertices
1025+
edges are. For clarity it's possible to have an edge defined in both
1026+
directions, but it'll be drawn once, from lower to higher number.
1027+
For example if vertex 2 is connected to vertex 0, that's ignored.
1028+
The config dictionary is used to initialize a Circle as the vertex.
1029+
(Or an Annulus if annulus=True is passed to this class.)
1030+
It will override values passed as vertex_config to the Graph.
1031+
Full example:
1032+
g = {0: [[0,0,0], [1, 2], {"color": BLUE}],
1033+
1: [[1,0,0], [0, 2], {"color": GRAY}],
1034+
2: [[0,1,0], [0, 1], {"color": PINK}]}
1035+
Graph(g,vertex_config={"radius": 0.2,"fill_opacity": 1},
1036+
edge_config={"stroke_width": 5,"color": RED})
1037+
1038+
An edge is instantiated as an ArcBetweenPoints, and optionally
1039+
individual config dictionaries can also be passed to them.
1040+
Example:
1041+
g = {0: [[0,0,0], [[1,{"angle": 2}], [2,{"color": WHITE}]]...
1042+
1043+
Use Graph.vertices/Graph.edges/Graph.annuli for drawing.
1044+
"""
1045+
def __init__(self, graph, vertex_config={}, edge_config={}, **kwargs):
1046+
if not all(isinstance(n,int) for n in graph.keys()):
1047+
raise ValueError("All keys for the Graph dictionary have to be of type int")
1048+
if not all(all(isinstance(m,int) or isinstance(m,list)
1049+
for m in n[1])
1050+
for n in graph.values()):
1051+
raise ValueError("Invalid Edge definition in Graph class. Use int or "
1052+
"[int,dict].")
1053+
1054+
self.graph = graph
1055+
self.vertex_config = vertex_config
1056+
self.edge_config = edge_config
1057+
if kwargs.get('annulus', False):
1058+
self.annulus = True
1059+
else:
1060+
self.annulus = False
1061+
self.make_graph()
1062+
1063+
def make_graph(self):
1064+
self.vertices = VGroup()
1065+
self.edges = VGroup()
1066+
self.annuli = VGroup()
1067+
for vertex, attributes in self.graph.items():
1068+
if self.annulus:
1069+
self.annuli.add(Annulus(**{**self.vertex_config,
1070+
**attributes[2]}).shift(attributes[0]))
1071+
else:
1072+
self.vertices.add(Circle(**{**self.vertex_config,
1073+
**attributes[2]}).shift(attributes[0]))
1074+
for edge_definition in attributes[1]:
1075+
if isinstance(edge_definition, int):
1076+
vertex_number=edge_definition
1077+
edge_kwargs={}
1078+
elif isinstance(edge_definition, list):
1079+
vertex_number=edge_definition[0]
1080+
edge_kwargs=edge_definition[1]
1081+
if vertex < vertex_number:
1082+
edge = ArcBetweenPoints(attributes[0],
1083+
self.graph[vertex_number][0],
1084+
**{"angle": 0,
1085+
**self.edge_config,
1086+
**edge_kwargs})
1087+
self.edges.add(edge)

manim/mobject/types/vectorized_mobject.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from ...utils.simple_functions import clip_in_place
1919
from ...utils.space_ops import rotate_vector
2020
from ...utils.space_ops import get_norm
21+
from ...utils.space_ops import shoelace
2122

2223
# TODO
2324
# - Change cubic curve groups to have 4 points instead of 3
@@ -898,6 +899,24 @@ def get_subcurve(self, a, b):
898899
vmob.pointwise_become_partial(self, a, b)
899900
return vmob
900901

902+
def get_orientation(self):
903+
return shoelace(self.get_start_anchors(), False, True)
904+
905+
def reverse_orientation(self):
906+
reversed_points = self.get_points()[::-1]
907+
self.clear_points()
908+
self.append_points(reversed_points)
909+
return self
910+
911+
def force_orientation(self, target_orientation):
912+
if not (target_orientation == "CW" or target_orientation == "CCW"):
913+
raise ValueError('Invalid input for force_orientation | Use "CW" or "CCW"')
914+
if not (self.get_orientation() == target_orientation):
915+
# Since we already assured the input is CW or CCW,
916+
# and the orientations don't match, we just reverse
917+
self.reverse_orientation()
918+
return self
919+
901920

902921
class VGroup(VMobject):
903922
def __init__(self, *vmobjects, **kwargs):

manim/utils/space_ops.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,4 +236,20 @@ def get_winding_number(points):
236236
d_angle = angle_of_vector(p2) - angle_of_vector(p1)
237237
d_angle = ((d_angle + PI) % TAU) - PI
238238
total_angle += d_angle
239-
return total_angle / TAU
239+
return total_angle / TAU
240+
241+
242+
def shoelace(x_y, absoluteValue=False, orientation=True):
243+
x = x_y[:, 0]
244+
y = x_y[:, 1]
245+
result = 0.5 * np.array(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1)))
246+
if absoluteValue:
247+
return abs(result)
248+
else:
249+
if orientation:
250+
if result > 0:
251+
return "CW"
252+
else:
253+
return "CCW"
254+
else:
255+
return result

0 commit comments

Comments
 (0)