Autodiff
pie.partial(target, var) is the derivative of the number target with respect to var. var can only be a Point or a Scalar. When var is a point, the result is the gradient: an arrow at the point, in the direction that increases target fastest. Its length is how fast target grows per unit moved.
The examples below use four functions that aren't built into pie: pie.angle, pie.area, pie.add and pie.mul. This site adds them with a small TypeScript file, and any pie site can add its own pie.* functions the same way: see Your own functions in the docs. Each one does its math with pie's math helpers (add, sub, mul, atan2, …) instead of JavaScript's + and *, so pie.partial can differentiate through it.
How the four functions are written
import { Node2NodeFunc } from '~/lib/nodes/functions/Node2NodeFunc';
import { AnyNode, PointNode, ScalarNode } from '~/lib/nodes/state/Node';
import { abs, add, atan2, div, mul, ref, scalar, sub } from '~/lib/nodes/jax';
// pie.angle(p1, vertex, p2): the angle at `vertex`, in radians, from 0 to pi.
export const Angle: Node2NodeFunc = {
keyword: 'angle', outputType: 'Scalar',
parameters: [{ name: 'point1', type: 'Point' }, { name: 'vertex', type: 'Point' }, { name: 'point2', type: 'Point' }],
compute(inputs: AnyNode[]) {
const [p1, v, p2] = inputs as PointNode[];
const ux = sub(ref(p1.x), ref(v.x));
const uy = sub(ref(p1.y), ref(v.y));
const wx = sub(ref(p2.x), ref(v.x));
const wy = sub(ref(p2.y), ref(v.y));
const cross = sub(mul(ref(ux), ref(wy)), mul(ref(uy), ref(wx)));
const dot = add(mul(ref(ux), ref(wx)), mul(ref(uy), ref(wy)));
return { main: { type: 'Scalar', value: atan2(abs(ref(cross)), ref(dot)) } };
},
};
// pie.area(v1, v2, v3): the area of the triangle.
export const Area: Node2NodeFunc = {
keyword: 'area', outputType: 'Scalar',
parameters: [{ name: 'vertex1', type: 'Point' }, { name: 'vertex2', type: 'Point' }, { name: 'vertex3', type: 'Point' }],
compute(inputs: AnyNode[]) {
const [a, b, c] = inputs as PointNode[];
const ux = sub(ref(b.x), ref(a.x));
const uy = sub(ref(b.y), ref(a.y));
const wx = sub(ref(c.x), ref(a.x));
const wy = sub(ref(c.y), ref(a.y));
const cross = sub(mul(ref(ux), ref(wy)), mul(ref(uy), ref(wx)));
return { main: { type: 'Scalar', value: div(abs(ref(cross)), scalar(2)) } };
},
};
// pie.add(a, b) and pie.mul(a, b): the sum and the product of two numbers.
export const Add: Node2NodeFunc = {
keyword: 'add', outputType: 'Scalar',
parameters: [{ name: 'a', type: 'Scalar' }, { name: 'b', type: 'Scalar' }],
compute(inputs: AnyNode[]) {
const [a, b] = inputs as ScalarNode[];
return { main: { type: 'Scalar', value: add(ref(a.value), ref(b.value)) } };
},
};
export const Mul: Node2NodeFunc = {
keyword: 'mul', outputType: 'Scalar',
parameters: [{ name: 'a', type: 'Scalar' }, { name: 'b', type: 'Scalar' }],
compute(inputs: AnyNode[]) {
const [a, b] = inputs as ScalarNode[];
return { main: { type: 'Scalar', value: mul(ref(a.value), ref(b.value)) } };
},
};
The angle of a triangle
Take the angle of a triangle at corner a, and its gradient with respect to a.
pie.clear()
a = pie.point(-1, 3, out='a')
b = pie.point(-3, -1, out='b')
c = pie.point(3, -1, out='c')
pie.triangle(a, b, c)
angle_a = pie.angle(b, a, c, out='angle_a') # in radians
pie.partial(pie.mul(10, angle_a), a, stroke='crimson', fill='crimson') # 10x, so the arrow is easy to see
The arrow seems to point nowhere in particular. Now draw the circle through the three corners. Its center is the circumcenter.
def circumcenter(a, b, c):
(ax, ay), (bx, by), (cx, cy) = a, b, c
d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
ux = ((ax**2 + ay**2) * (by - cy) + (bx**2 + by**2) * (cy - ay) + (cx**2 + cy**2) * (ay - by)) / d
uy = ((ax**2 + ay**2) * (cx - bx) + (bx**2 + by**2) * (ax - cx) + (cx**2 + cy**2) * (bx - ax)) / d
return (ux, uy)
center = pie.lift(circumcenter, pie.Point)
o = center(a, b, c, fill='gray')
pie.circle(o, pie.distance(o, a), stroke='gray')
pie.line(a, o, stroke='gray')
The arrow points straight at the circumcenter. Why: slide a along the circle, with b and c fixed, and the angle at a doesn't change (the inscribed angle theorem). A gradient is always at right angles to the curve along which the value stays the same. Here that curve is the circle, so the gradient lies along its radius.
Move a and check that it still holds:
pie.translate_point_to(a, 2, 2, animate=True)
pie.translate_point_to(a, -2.5, 1.5, animate=True)
The area of a triangle
The gradient of the area with respect to a corner is at right angles to the opposite side, and its length is half that side.
pie.clear()
a = pie.point(-1, 1, out='a')
b = pie.point(-3, -3, out='b')
c = pie.point(3, -3, out='c')
pie.triangle(a, b, c, fill='#fde68a')
area = pie.area(a, b, c, out='area')
pie.partial(area, a, stroke='crimson', fill='crimson')
def show_area(x):
return f"area = {x:.2f}"
pie.text(pie.lift(show_area, pie.Text)(area), 0, -4, 16)
bc has length 6, so the arrow has length 3. Moving a parallel to bc, at right angles to the arrow, leaves the area unchanged:
pie.translate_point_by(a, 4, 0, animate=True)
pie.translate_point_by(a, -6, 0, animate=True)
Tilt bc and the arrow tilts with it:
pie.translate_point_to(c, 3, 0, animate=True)
The two distances of an ellipse
Take the distances from a point p to two fixed points, the foci, and add them. Every point with the same total lies on one ellipse, so draw that ellipse through p.
pie.clear()
f1 = pie.point(-3, 0, out='f1')
f2 = pie.point(3, 0, out='f2')
p = pie.point(1, 2.5, out='p', fill='crimson')
pie.line(f1, p, stroke='gray')
pie.line(f2, p, stroke='gray')
total = pie.add(pie.distance(p, f1), pie.distance(p, f2), out='total')
pie.ellipse_from_foci(f1, f2, total, stroke='teal')
pie.partial(total, p, stroke='crimson', fill='crimson')
The gradient is at right angles to the ellipse, as before. It is also the sum of two arrows of length 1, one pointing away from each focus, so it splits the angle between the gray lines in half. That means the two gray lines meet the ellipse at equal angles. This is why light sent from one focus bounces off an elliptical mirror into the other.
Move p and the ellipse changes to pass through it:
pie.translate_point_to(p, -2, -1.5, animate=True)
pie.translate_point_to(p, 3.5, 1, animate=True)