Created
May 24, 2020 09:18
-
-
Save jajaperson/275f6a5ab52797f038d031f27f2f0034 to your computer and use it in GitHub Desktop.
My first experience with manim. Steals some ideas from 3b1b's episode of EOLA on dot products.
This file contains 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
#!/usr/bin/env python | |
from manimlib.imports import * | |
V_COLOR = YELLOW | |
W_COLOR = MAROON_B | |
SUM_COLOR = PINK | |
class ShowProjection(VectorScene): | |
CONFIG = { | |
"v_coords": [4, 1], | |
"w_coords": [2, -1], | |
"v_color": V_COLOR, | |
"w_color": W_COLOR, | |
"project_onto_v": True, | |
} | |
def construct(self): | |
self.add_plane(animate=True) | |
self.add_symbols() | |
self.add_vectors() | |
self.stable_span() | |
self.project() | |
self.scalar_and_vector() | |
def add_symbols(self): | |
v = matrix_to_mobject(self.v_coords).set_color(self.v_color) | |
w = matrix_to_mobject(self.w_coords).set_color(self.w_color) | |
v.add_background_rectangle() | |
w.add_background_rectangle() | |
projection_text = TextMobject("projected onto") | |
pr = VGroup(w if self.project_onto_v else v, projection_text, v if self.project_onto_v else w) | |
pr.arrange(RIGHT, buff = SMALL_BUFF) | |
pr.space_out_submobjects(factor=1.1) | |
pr.to_corner(UP+LEFT) | |
self.play(Write(pr), run_time = 1) | |
for array, char in zip([w, v], ["w", "v"]): | |
brace = Brace(array, DOWN) | |
label = brace.get_text("$\\vec{\\textbf{%s}}$"%char) | |
label.set_color(array.get_color()) | |
self.play( | |
GrowFromCenter(brace), | |
Write(label), | |
run_time = 1 | |
) | |
self.projection_desc = pr | |
def add_vectors(self): | |
self.v = Vector(self.v_coords, color = self.v_color) | |
self.w = Vector(self.w_coords, color = self.w_color) | |
self.play(ShowCreation(self.v)) | |
self.play(ShowCreation(self.w)) | |
for vect, char, direction in zip( | |
[self.v, self.w], ["v", "w"], [DOWN+RIGHT, DOWN] | |
): | |
label = TexMobject("\\vec{\\textbf{%s}}"%char) | |
label.next_to(vect.get_end(), direction) | |
label.set_color(vect.get_color()) | |
self.play(Write(label, run_time = 1)) | |
self.stable_vect = self.v if self.project_onto_v else self.w | |
self.proj_vect = self.w if self.project_onto_v else self.v | |
def stable_span(self): | |
self.span = Line(LEFT, RIGHT) | |
self.span.scale(FRAME_X_RADIUS * 1.5) | |
self.span.rotate(self.stable_vect.get_angle()) | |
self.play(ShowCreation(self.span)) | |
self.wait() | |
def project(self): | |
vdotw = np.dot(self.v.get_end(), self.w.get_end()) | |
projected = Vector( | |
self.stable_vect.get_end() * ( | |
vdotw / self.stable_vect.get_length()**2 | |
), | |
color = self.proj_vect.get_color() | |
) | |
self.projection_line = Line( | |
self.proj_vect.get_end(), | |
projected.get_end(), | |
color = GREY | |
) | |
self.play(ShowCreation(self.projection_line)) | |
self.add(self.proj_vect.copy().fade()) | |
self.play(Transform(self.proj_vect, projected), Transform(self.span, self.span.copy().fade())) | |
self.wait() | |
def scalar_and_vector(self): | |
proj_brace = Brace(Line(ORIGIN, self.proj_vect.get_length()*RIGHT*np.sign(np.dot(self.proj_vect.get_end(), self.stable_vect.get_end()))), UP) | |
proj_brace.rotate(self.stable_vect.get_angle()) | |
desc_vect = TextMobject( | |
"This new vector is called the vector projection of ", | |
"$\\vec{\\textbf{%s}}$"%self.get_proj_char(), | |
" onto ", | |
"$\\vec{\\textbf{%s}}$"%self.get_stable_char(), | |
".", | |
arg_seperator = "" | |
) | |
desc_vect.scale(0.9) | |
desc_vect.to_edge(DOWN) | |
desc_vect_proj = desc_vect[1] | |
desc_vect_proj.set_color(self.proj_vect.get_color()) | |
desc_vect_stable = desc_vect[3] | |
desc_vect_stable.set_color(self.stable_vect.get_color()) | |
desc_scalar = TextMobject( | |
"The length of this vector is called the scalar projection of ", | |
"$\\vec{\\textbf{%s}}$"%self.get_proj_char(), | |
" onto ", | |
"$\\vec{\\textbf{%s}}$"%self.get_stable_char(), | |
".", | |
arg_seperator = "" | |
) | |
desc_scalar.scale(0.9) | |
desc_scalar.to_edge(DOWN) | |
desc_scalar_proj = desc_scalar[1] | |
desc_scalar_proj.set_color(self.proj_vect.get_color()) | |
desc_scalar_stable = desc_scalar[3] | |
desc_scalar_stable.set_color(self.stable_vect.get_color()) | |
self.play(Write(desc_vect), GrowFromCenter(proj_brace)) | |
self.wait() | |
self.play(Transform(desc_vect, desc_scalar)) | |
self.wait() | |
def get_stable_char(self): | |
return "v" if self.project_onto_v else "w" | |
def get_proj_char(self): | |
return "w" if self.project_onto_v else "v" | |
class ShowProjectionFlipped(ShowProjection): | |
CONFIG = { | |
"project_onto_v": False | |
} | |
class DotProduct(VectorScene): | |
CONFIG = { | |
"v_coords": [4, 1], | |
"w_coords": [2, -1], | |
"v_color": V_COLOR, | |
"w_color": W_COLOR, | |
"project_onto_v": True, | |
} | |
def construct(self): | |
self.add_plane() | |
self.add_symbols() | |
self.redraw_vectors() | |
self.show_lengths() | |
def add_symbols(self): | |
title = TextMobject("Dot product") | |
title.scale(1.75) | |
self.play(Write(title)) | |
self.wait(duration=1.5) | |
v = matrix_to_mobject(self.v_coords).set_color(self.v_color) | |
w = matrix_to_mobject(self.w_coords).set_color(self.w_color) | |
v.add_background_rectangle() | |
w.add_background_rectangle() | |
dot = TexMobject("\\cdot") | |
dot.set_color(RED) | |
eq = VGroup(v, dot, w) | |
eq.arrange(RIGHT, buff=SMALL_BUFF) | |
eq.scale(1.5) | |
self.play(FadeOut(title)) | |
self.play(Write(eq)) | |
self.remove(eq) | |
self.play(eq.scale, 1/1.5, eq.to_corner, UP + LEFT) | |
for array, char in zip([eq[0], eq[2]], ["v", "w"]): | |
brace = Brace(array, DOWN) | |
label = brace.get_text("$\\vec{\\textbf{%s}}$"%char) | |
label.set_color(array.get_color()) | |
self.play( | |
GrowFromCenter(brace), | |
Write(label), | |
run_time = 0.2 | |
) | |
self.dot_product = eq | |
def redraw_vectors(self): | |
self.v = Vector(self.v_coords, color = self.v_color) | |
self.w = Vector(self.w_coords, color = self.w_color) | |
self.stable_vect = self.v if self.project_onto_v else self.w | |
self.proj_vect = self.w if self.project_onto_v else self.v | |
self.span = Line(LEFT, RIGHT) | |
self.span.scale(FRAME_X_RADIUS * 1.5) | |
self.span.rotate(self.stable_vect.get_angle()) | |
set1 = [ShowCreation(self.span), ShowCreation(self.v), ShowCreation(self.w)] | |
for vect, char, direction in zip( | |
[self.v, self.w], ["v", "w"], [DOWN+RIGHT, DOWN] | |
): | |
label = TexMobject("\\vec{\\textbf{%s}}"%char) | |
label.next_to(vect.get_end(), direction) | |
label.set_color(vect.get_color()) | |
set1.append(Write(label)) | |
self.play(*set1, run_time = 0.2) | |
self.vdotw = np.dot(self.v.get_end(), self.w.get_end()) | |
projected = Vector( | |
self.stable_vect.get_end() * ( | |
self.vdotw / self.stable_vect.get_length()**2 | |
), | |
color = self.proj_vect.get_color() | |
) | |
projection_line = Line( | |
self.proj_vect.get_end(), | |
projected.get_end(), | |
color = GREY | |
) | |
set2 = [ShowCreation(projected), ShowCreation(projection_line)] | |
self.play(*set2, run_time = 0.4) | |
self.projected_vect = projected | |
def show_lengths(self): | |
product = TexMobject( | |
"=", | |
"(", | |
"\\text{Length of projected $\\vec{\\textbf{%s}}$}"%self.get_proj_char(), | |
")", | |
"\\times" | |
"(", | |
"\\text{Length of $\\vec{\\textbf{%s}}$}"%self.get_stable_char(), | |
")", | |
arg_seperator = "" | |
) | |
product.scale(0.9) | |
product.next_to(self.dot_product) | |
proj_l = product[2] | |
proj_l.set_color(self.proj_vect.get_color()) | |
stable_l = product[5] | |
stable_l.set_color(self.stable_vect.get_color()) | |
product.remove(proj_l, stable_l), | |
for desc in stable_l, proj_l: | |
desc.add_to_back(BackgroundRectangle(desc)) | |
desc.start = desc.copy() | |
proj_brace, stable_brace = braces = [ | |
Brace(Line(ORIGIN, vect.get_length()*RIGHT*sign), UP) | |
for vect in [self.projected_vect, self.stable_vect] | |
for sign in [np.sign(np.dot(vect.get_end(), self.stable_vect.get_end()))] | |
] | |
proj_brace.put_at_tip(proj_l.start) | |
proj_brace.desc = proj_l.start | |
stable_brace.put_at_tip(stable_l.start) | |
stable_brace.desc = stable_l.start | |
for brace in braces: | |
brace.rotate(self.stable_vect.get_angle()) | |
brace.desc.rotate(self.stable_vect.get_angle()) | |
self.play( | |
GrowFromCenter(proj_brace), | |
Write(proj_l.start, run_time = 2) | |
) | |
self.wait() | |
self.play( | |
Transform(proj_l.start, proj_l), | |
FadeOut(proj_brace) | |
) | |
self.play( | |
GrowFromCenter(stable_brace), | |
Write(stable_l.start, runtime = 2), | |
Animation(self.stable_vect) | |
) | |
self.wait() | |
self.play( | |
Transform(stable_l.start, stable_l), | |
FadeOut(stable_brace), | |
Write(product) | |
) | |
self.wait() | |
product.add(stable_l.start, proj_l.start) | |
self.product = product | |
def get_stable_char(self): | |
return "v" if self.project_onto_v else "w" | |
def get_proj_char(self): | |
return "w" if self.project_onto_v else "v" | |
class DotProductFlipped(DotProduct): | |
CONFIG = { | |
"project_onto_v": False | |
} | |
class ComputingDotProduct(Scene): | |
CONFIG = { | |
"v_coords": [4, 1], | |
"w_coords": [2, -1], | |
"v_color": V_COLOR, | |
"w_color": W_COLOR, | |
"project_onto_v": True, | |
} | |
def construct(self): | |
self.add_symbols_from_prev() | |
self.add_title() | |
self.show_algebra() | |
def add_symbols_from_prev(self): | |
old_v = matrix_to_mobject(self.v_coords).set_color(self.v_color) | |
old_w = matrix_to_mobject(self.w_coords).set_color(self.w_color) | |
old_v.add_background_rectangle() | |
old_w.add_background_rectangle() | |
old_dot = TexMobject("\\cdot") | |
old_dot.set_color(RED) | |
eq = VGroup(old_v, old_dot, old_w) | |
eq.arrange(RIGHT, buff=SMALL_BUFF) | |
eq.to_corner(UP + LEFT) | |
self.add(eq) | |
v = Matrix(self.v_coords) | |
w = Matrix(self.w_coords) | |
inter_array_dot = TexMobject("\\cdot").scale(1.5) | |
dot_product = VGroup(v, inter_array_dot, w) | |
dot_product.arrange(RIGHT, buff = MED_SMALL_BUFF/2) | |
dot_product.to_edge(LEFT) | |
pairs = list(zip(v.get_entries(), w.get_entries())) | |
for pair, color in zip(pairs, [X_COLOR, Y_COLOR, Z_COLOR]): | |
VGroup(*pair).set_color(color) | |
self.play( | |
Transform(eq, dot_product) | |
) | |
self.v = v | |
self.w = w | |
self.dot_product = dot_product | |
self.pairs = pairs | |
def add_title(self): | |
title = TextMobject("Computing the dot product") | |
title.scale(1.5) | |
title.to_edge(UP) | |
self.play(Write(title)) | |
def show_algebra(self): | |
dot = TexMobject("\\cdot") | |
products = VGroup(*[ | |
VGroup( | |
c1.copy(), dot.copy(), c2.copy() | |
).arrange(RIGHT, buff = SMALL_BUFF) | |
for c1, c2 in self.pairs | |
]) | |
products.arrange(DOWN, buff = LARGE_BUFF) | |
products.next_to(self.dot_product, RIGHT, buff = LARGE_BUFF) | |
self.play(Transform( | |
VGroup(*it.starmap(VGroup, self.pairs)).copy(), | |
products, | |
path_arc = -np.pi/2, | |
run_time = 2 | |
)) | |
self.remove(*self.get_mobjects_from_last_animation()) | |
self.add(products) | |
self.wait() | |
products.target = products.copy() | |
symbols = VGroup(*list(map(TexMobject, ["=", "+"]))) | |
final_sum = VGroup(*it.chain(*list(zip( | |
symbols, products.target | |
)))) | |
final_sum.arrange(RIGHT, buff = LARGE_BUFF) | |
final_sum.next_to(self.dot_product, RIGHT, buff = LARGE_BUFF) | |
self.play( | |
Write(symbols), | |
Transform(products, products.target, path_arc = np.pi/2) | |
) | |
self.wait() | |
result = VGroup( | |
TexMobject("="), | |
TexMobject(np.dot(self.v_coords, self.w_coords)).set_color(SUM_COLOR) | |
) | |
result.arrange(RIGHT, buff = LARGE_BUFF) | |
result.next_to(final_sum, RIGHT, buff = LARGE_BUFF) | |
self.play( | |
Write(result) | |
) | |
class ComputingDotProductAlt(ComputingDotProduct): | |
CONFIG = { | |
"v_coords": [8, 3], | |
"w_coords": [-12, -2], | |
} | |
class CommutativeProperty(Scene): | |
CONFIG = { | |
"v_coords": [4, 1], | |
"w_coords": [2, -1], | |
"v_color": V_COLOR, | |
"w_color": W_COLOR, | |
"project_onto_v": True, | |
} | |
def construct(self): | |
self.introduce_property() | |
self.show_algebra() | |
def introduce_property(self): | |
l1 = TextMobject("The dot product is ", "commutative,", arg_seperator = "") | |
l2 = TextMobject("i.e. order doesn't matter.") | |
introduction = VGroup(l1, l2) | |
introduction.arrange(DOWN) | |
introduction.scale(1.5) | |
self.play(Write(l1)) | |
self.play(l1[1].set_color, YELLOW, ShowPassingFlashAround(l1[1])) | |
self.play(Write(l2)) | |
self.introduction = introduction | |
def show_algebra(self): | |
v = Matrix(self.v_coords) | |
w = Matrix(self.w_coords) | |
inter_array_dot = TexMobject("\\cdot").scale(1.5) | |
dot_product = VGroup(v, inter_array_dot, w) | |
dot_product.arrange(RIGHT, buff = MED_SMALL_BUFF/2) | |
pairs = list(zip(v.get_entries(), w.get_entries())) | |
for pair, color in zip(pairs, [X_COLOR, Y_COLOR, Z_COLOR]): | |
VGroup(*pair).set_color(color) | |
dot = TexMobject("\\cdot") | |
symbols = VGroup(*list(map(TexMobject, ["=", "+"]))) | |
products = VGroup(*[ | |
VGroup( | |
c1.copy(), dot.copy(), c2.copy() | |
).arrange(RIGHT, buff = SMALL_BUFF) | |
for c1, c2 in pairs | |
]) | |
dp_sum = VGroup(*it.chain(*list(zip( | |
symbols, | |
products | |
))), TexMobject("=")) | |
dp_sum.arrange(RIGHT, buff = MED_LARGE_BUFF) | |
reconstructed_pairs = VGroup(*[ | |
VGroup( | |
c1.copy(), c2.copy() | |
) | |
for c1, _, c2 in products | |
]) | |
# reconstructed_vects = zip(reconstructed_pairs.copy()) | |
flipped_dot_product = dot_product.copy() | |
flipped_dot_product.arrange(LEFT, buff = MED_SMALL_BUFF/2) | |
algebra = VGroup( | |
dot_product, | |
dp_sum, | |
flipped_dot_product | |
) | |
algebra.arrange(RIGHT, buff = MED_LARGE_BUFF) | |
self.play(Transform(self.introduction, dot_product)) | |
self.play(Transform( | |
VGroup(*it.starmap(VGroup, pairs)).copy(), | |
dp_sum, | |
path_arc = np.pi/2 | |
)) | |
self.play(Transform( | |
VGroup(*it.starmap(VGroup, reconstructed_pairs)).copy(), | |
flipped_dot_product, | |
path_arc = np.pi/2 | |
)) | |
class ComputingScalarProjection(Scene): | |
CONFIG = { | |
"v_coords": [4, 1], | |
"w_coords": [2, -1], | |
"v_color": V_COLOR, | |
"w_color": W_COLOR, | |
"project_onto_v": True, | |
} | |
def construct(self): | |
l1 = TexMobject( | |
"\\text{Since the dot product }", | |
"\\vec{\\textbf{%s}}"%self.get_stable_char(), | |
"\\cdot", "\\vec{\\textbf{%s}}"%self.get_proj_char(), | |
"\\text{ is }" | |
) | |
l1[1].set_color(self.v_color if self.project_onto_v else self.w_color) | |
l1[3].set_color(self.w_color if self.project_onto_v else self.v_color) | |
l2 = TexMobject( | |
"(", | |
"\\text{Length of projected $\\vec{\\textbf{%s}}$}"%self.get_proj_char(), | |
")\\times(", | |
"\\text{Length of $\\vec{\\textbf{%s}}$}"%self.get_stable_char(), | |
")" | |
) | |
l2[1].set_color(self.w_color if self.project_onto_v else self.v_color) | |
l2[3].set_color(self.v_color if self.project_onto_v else self.w_color) | |
explanation1 = VGroup(l1, l2) | |
explanation1.arrange(DOWN) | |
proj_val = TexMobject("\\text{proj}_{\\vec{\\textbf{%s}}}"%self.get_stable_char(), "\\vec{\\textbf{%s}}"%self.get_proj_char(), arg_separator = "") | |
frac1 = TexMobject( | |
f'\\frac{{(\\text{{Length of projected $\\vec{{\\textbf{{{self.get_stable_char()}}}}}$}})\\times(\\text{{Length of $\\vec{{\\textbf{{{self.get_proj_char()}}}}}$}})}}{{(\\text{{Length of $\\vec{{\\textbf{{{self.get_stable_char()}}}}}$}})}}' | |
) | |
eq1 = VGroup(proj_val, TexMobject("="), frac1) | |
eq1.arrange(RIGHT, buff = MED_LARGE_BUFF) | |
frac2 = TexMobject( | |
f'\\frac{{\\vec{{\\textbf{{{self.get_proj_char()}}}}} \\cdot \\vec{{\\textbf{{{self.get_stable_char()}}}}}}}{{\\norm{{\\vec{{\\textbf{{{self.get_stable_char()}}}}}}}}}' | |
) | |
eq2 = VGroup(proj_val.copy(), TexMobject("="), frac2) | |
eq2.arrange(RIGHT, buff = MED_LARGE_BUFF) | |
self.play(Write(explanation1)) | |
self.wait(2) | |
self.play(FadeOut(l1)) | |
self.play(Transform(l2, eq1)) | |
self.wait(3) | |
self.play(Transform(l2, eq2)) | |
self.wait(3) | |
def get_stable_char(self): | |
return "v" if self.project_onto_v else "w" | |
def get_proj_char(self): | |
return "w" if self.project_onto_v else "v" | |
class ComputingVectorProjection(Scene): | |
CONFIG = { | |
"v_coords": [4, 1], | |
"w_coords": [2, -1], | |
"v_color": V_COLOR, | |
"w_color": W_COLOR, | |
"project_onto_v": True, | |
} | |
def construct(self): | |
l1 = TextMobject( | |
"The vector projection of ", | |
"$\\vec{\\textbf{%s}}$"%self.get_proj_char(), | |
" onto ", | |
"$\\vec{\\textbf{%s}}$"%self.get_stable_char(), | |
" is", | |
arg_separator = "" | |
) | |
l1[1].set_color(self.w_color if self.project_onto_v else self.v_color) | |
l1[3].set_color(self.v_color if self.project_onto_v else self.w_color) | |
l2 = TextMobject( | |
"the same as the scalar projection in" | |
) | |
l3 = TextMobject( | |
"the direction of, ", | |
"$\\vec{\\textbf{%s}}$"%self.get_stable_char(), | |
arg_separator = "" | |
) | |
l3[1].set_color(self.v_color if self.project_onto_v else self.w_color) | |
explanation = VGroup(l1, l2, l3) | |
explanation.arrange(DOWN) | |
proj_val = TexMobject("\\vec{\\textbf{\\text{proj}}}_{\\vec{\\textbf{%s}}}"%self.get_stable_char(), "\\vec{\\textbf{%s}}"%self.get_proj_char(), arg_separator = "") | |
scaling = TexMobject( | |
"\\left(", | |
"\\text{proj}_{\\vec{\\textbf{%s}}}"%self.get_stable_char(), | |
"\\vec{\\textbf{%s}}"%self.get_proj_char(), | |
"\\right)", | |
"\\hat{\\textbf{%s}}"%self.get_stable_char(), | |
arg_separator = "" | |
) | |
eq1 = VGroup(proj_val, TexMobject("="), scaling) | |
eq1.arrange(RIGHT, buff = MED_LARGE_BUFF) | |
frac1 = TexMobject( | |
f'\\frac{{\\vec{{\\textbf{{{self.get_proj_char()}}}}} \\cdot \\vec{{\\textbf{{{self.get_stable_char()}}}}}}}{{\\norm{{\\vec{{\\textbf{{{self.get_stable_char()}}}}}}}}}', | |
"\\times", | |
f'\\frac{{\\vec{{\\textbf{{{self.get_stable_char()}}}}}}}{{\\norm{{\\vec{{\\textbf{{{self.get_stable_char()}}}}}}}}}', | |
arg_separator = "" | |
) | |
eq2 = VGroup(proj_val.copy(), TexMobject("="), frac1) | |
eq2.arrange(RIGHT, buff = MED_LARGE_BUFF) | |
frac2 = TexMobject( | |
f'\\frac{{\\vec{{\\textbf{{{self.get_proj_char()}}}}} \\cdot \\vec{{\\textbf{{{self.get_stable_char()}}}}}}}{{\\norm{{\\vec{{\\textbf{{{self.get_stable_char()}}}}}}}^2}}', | |
"\\times \\vec{\\textbf{%s}}"%self.get_stable_char(), | |
arg_separator = "" | |
) | |
eq3 = VGroup(proj_val.copy(), TexMobject("="), frac2) | |
eq3.arrange(RIGHT, buff = MED_LARGE_BUFF) | |
frac3 = TexMobject( | |
f'\\frac{{\\vec{{\\textbf{{{self.get_proj_char()}}}}} \\cdot \\vec{{\\textbf{{{self.get_stable_char()}}}}}}}{{\\vec{{\\textbf{{{self.get_stable_char()}}}}} \\cdot \\vec{{\\textbf{{{self.get_stable_char()}}}}}}}', | |
"\\times \\vec{\\textbf{%s}}"%self.get_stable_char(), | |
arg_separator = "" | |
) | |
eq4 = VGroup(proj_val.copy(), TexMobject("="), frac3) | |
eq4.arrange(RIGHT, buff = MED_LARGE_BUFF) | |
self.play(Write(explanation)) | |
self.wait(2) | |
self.play(Transform(explanation, eq1)) | |
self.wait(3) | |
self.play(Transform(explanation, eq2)) | |
self.play(Transform(explanation, eq3)) | |
self.play(Transform(explanation, eq4)) | |
self.wait(3) | |
def get_stable_char(self): | |
return "v" if self.project_onto_v else "w" | |
def get_proj_char(self): | |
return "w" if self.project_onto_v else "v" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment