Hi! Inspired by another coding train video here: https://www.youtube.com/watch?v=p7IGZTjC008&t=4s , I decided to try to implement it myself in python and see what happens. Ok, so it seems that paper marbling is an art-style. It is described here in detail in a wikipedia article: https://en.wikipedia.org/wiki/Paper_marbling . I think we are going to come across some fluid dynamics and shit like that soon enough…
Ok, so let’s get a feel for what we are about to do before jumping head first into coding…
Ok, so apparently there exists a mathematical solution, which doesn’t involve any fluid dynamics, but approximates it good enough to look similar. This is also computationally less expensive than a full-blown fluid simulation.
Let’s create the inkdrop object in a file called inkdrop.py .
Here is my very primitive start of the inkdrop object:
class InkDrop:
def __init__(self, r, x0, y0) -> None: # Constructor...
self.r = r
self.x0 = x0
self.y0 = y0
return
def update(self) -> None: # Update physics maybe... Stub for now.
return
Ok, so let’s say we drop an Inkdrop object on top of another inkdrop object. We want this new inkdrop to avoid the previously placed and sort-of “go around” it. Here is some of the mathematics, which describe this: https://people.csail.mit.edu/jaffer/Marbling/Mathematics and here is the very original “Mathematical Marbling” paper http://www.cad.zju.edu.cn/home/jin/cga2012/mmarbling.pdf .
First of all, we need to modify our inkdrop class to have a list of points which lie on the circumference, because we need those for the computation of the way how the inkrops “avoid” each other.
Here is the new modified inkdrop object:
CIRCLE_RESOLUTION = 10 # How many "circumference points" there are for each circle.
SCALE_FACTOR = 100 # Scale the coordinates by this much when drawing..
import math
class InkDrop:
def __init__(self, r, x0, y0) -> None: # Constructor...
self.r = r
self.x0 = x0
self.y0 = y0
self.vertices = self.construct_vertices() # These are the very initial points on the circumference
return
def construct_vertices(self) -> list: # Returns a list of tuples each of which describes an x,y point on the circumference of the circle.
cur_angle = 0.0 # Initial calculation angle
angle_step = 2*math.pi/(CIRCLE_RESOLUTION) # How many radians to step forward on each step
out = []
for i in range(CIRCLE_RESOLUTION):
# Add point.
dx = math.cos(cur_angle) * self.r
dy = math.sin(cur_angle) * self.r
new_point = tuple((self.x0+dx, self.y0+dy))
out.append(new_point)
cur_angle += angle_step # Update the angle.
return out
def draw(self, t) -> None: # Render the shape. "t" is the turtle object we use to draw with.
# Go through all of the vertices in order.
t.penup()
t.goto(self.vertices[0][0]*SCALE_FACTOR, self.vertices[0][1]*SCALE_FACTOR) # Go to the first vertex
t.pendown()
for vert in self.vertices[1:]: # Skip over first vertex here, because we already are there.
t.goto(vert[0]*SCALE_FACTOR, vert[1]*SCALE_FACTOR)
# Go back to the first vertex to close the loop.
t.goto(self.vertices[0][0]*SCALE_FACTOR, self.vertices[0][1]*SCALE_FACTOR)
t.penup() # Stop drawing...
return
def update(self) -> None: # Update physics maybe... Stub for now.
return
let’s test it out!
Here is the contents of my main.py file (for now) … :
import turtle
from inkdrop import *
import time
def show_circle() -> None:
t = turtle.Turtle() # Create a new turtle object.
# __init__(self, r, x0, y0)
drop = InkDrop(3, 0, 0) # Circle at (0,0) with radius 3.
# Render the drop.
drop.draw(t)
time.sleep(5) # Wait for a bit for the user to see the result...
return
if __name__=="__main__":
# Main program entry point.
show_circle()
exit(0)
and it seems to work! Good!
Let’s create a loop, which just shows all of the circles over and over again… and then let’s create an event handler which adds a new circle when the canvas gets clicked…
After a bit of fiddling around, I now have this as my main function:
import turtle
from inkdrop import *
import time
CIRC_RADIUS = 0.5 # Radius of each circle when added.
new_circle = False
new_x = None
new_y = None
def new_drop(x, y) -> None: # Will be called when the canvas gets clicked. Should create a new drop at x,y
global drops # We need to modify this global variable, therefore we need this here.
global new_x
global new_y
global new_circle
print("Clicked at "+str(x)+", "+str(y)+" .")
# When drawing, we scale by SCALE_FACTOR , therefore we need to divide by that here.
new_x = x/SCALE_FACTOR
new_y = y/SCALE_FACTOR
new_circle = True
return
def main_loop() -> None:
global new_circle # We modify this.
t = turtle.Turtle() # Create a new turtle object.
# __init__(self, r, x0, y0)
#t.tracer(0,0)
turtle.tracer(0,0)
#drop = InkDrop(3, 0, 0) # Circle at (0,0) with radius 3.
# Render the drop.
#drop.draw(t)
turtle.onscreenclick(new_drop) # Setup click handlers
drops = [] # This is our main inkdrops list. We will use this to store all of our drop objects...
t.dot()
while True: # Main program loop
if not new_circle:
# Just show each circle. This is because we haven't added a new circle.
for drop in drops:
drop.draw(t)
#print("Drew all dots!")
else:
# Handle new circle.
print("new_circle == True")
new_circ = InkDrop(CIRC_RADIUS, new_x, new_y)
drops.append(new_circ)
new_circle = False
time.sleep(0.01) # No need to draw faster than that
turtle.update()
t.clear()
#time.sleep(5) # Wait for a bit for the user to see the result...
return
if __name__=="__main__":
# Main program entry point.
main_loop()
exit(0)
and it seems to work fine.
Ok, so now instead of automatically just putting the circle into the list, we need to modify the new circle with the pre-existing circles, such that the new circle avoids the pre-existing circles.
I actually got it the wrong way around, the new circle which we are adding is supposed to be an actual circle. It is the other circles which need to be modified to accommodate the new circle.
Let’s program a method for our inkdrop object which updates the vertices with the given formula.
Here:
def marble(self, other) -> None: # This methods updates the vertices of this drop object using the other circle object.
for i in range(len(self.vertices)): # Loop over each vertex.
other_center = tuple((other.x0, other.y0))
other_r = other.r
#p_minus_c = tuple((self.x0 - other_center[0], self.y0 - other_center[1]))
p_minus_c = tuple((self.vertices[i][0] - other_center[0], self.vertices[i][1] - other_center[1])) # self.vertices
magnitude = math.sqrt(p_minus_c[0]**2 + p_minus_c[1]**2)
root_val = math.sqrt(1 + (other_r * other_r) / (magnitude * magnitude))
final_vec = tuple((other_center[0] + root_val * p_minus_c[0], other_center[1] + root_val * p_minus_c[1]))
self.vertices[i] = final_vec
return
That seems to do the trick!
Ok, so that is quite good. I am thinking that we should also implement some other transformations while we are at it.
The lines called “tine” lines are described in the paper in section 3.1.2 . It defines another transformation function, which draws these “tine” lines.
To implement tinge lines, we first need a way to get user defined lines. I am going to use the “t” keyboard key to signify a tine line (“t” as in “tine”) . The line starts from where the mouse position, and then ends at the place where you release the t key.
Here is my current code (it actually works with the “a” key):
import turtle
from inkdrop import *
import time
CIRC_RADIUS = 0.5 # Radius of each circle when added.
new_circle = False
new_x = None
new_y = None
# These will be used in the creation of the tine lines when the user presses up.
p0 = None
p1 = None
new_tine = False
class WatchedKey:
def __init__(self, key):
self.key = key
self.down = False
turtle.onkeypress(self.press, key)
turtle.onkeyrelease(self.release, key)
def press(self):
self.down = True
def release(self):
self.down = False
a_key = WatchedKey("a")
def new_drop(x, y) -> None: # Will be called when the canvas gets clicked. Should create a new drop at x,y
global drops # We need to modify this global variable, therefore we need this here.
global new_x
global new_y
global new_circle
global a_key
global p0
global p1
global new_tine
print("Clicked at "+str(x)+", "+str(y)+" .")
# Check if we are pressing "a" at the same time, if yes, then we have a tine.
if a_key.down:
print("Tine press!!!!!!!!!!!!")
# Tine key press.
if not p0: # assign p0 and return
p0 = tuple((x/SCALE_FACTOR, y/SCALE_FACTOR))
return
elif not p1:
p1 = tuple((x/SCALE_FACTOR, y/SCALE_FACTOR))
# We should have p0
assert p0 != None
new_tine = True # Message the main loop about a new tine.
return
# When drawing, we scale by SCALE_FACTOR , therefore we need to divide by that here.
new_x = x/SCALE_FACTOR
new_y = y/SCALE_FACTOR
new_circle = True
return
def process_tine(drops, p0, p1) -> None: # This applies the tine transformation to each of the drops.
return # Just a stub for now.
def main_loop() -> None:
global new_circle # We modify this.
# These two are required for the tine lines
global p0
global p1
global new_tine
t = turtle.Turtle() # Create a new turtle object.
# __init__(self, r, x0, y0)
#t.tracer(0,0)
turtle.tracer(0,0)
#drop = InkDrop(3, 0, 0) # Circle at (0,0) with radius 3.
# Render the drop.
#drop.draw(t)
turtle.onscreenclick(new_drop) # Setup click handlers
#turtle.onkey(new_tine, "Up")
turtle.listen()
drops = [] # This is our main inkdrops list. We will use this to store all of our drop objects...
#t.dot()
while True: # Main program loop
if not new_circle:
# Just show each circle. This is because we haven't added a new circle.
for drop in drops:
drop.draw(t)
#print("Drew all dots!")
else:
# Handle new circle.
#print("new_circle == True")
new_circ = InkDrop(CIRC_RADIUS, new_x, new_y)
# Marble every other drop, before adding the new drop to the list.
for drop in drops:
drop.marble(new_circ)
drops.append(new_circ)
new_circle = False
if new_tine: # We have a new tine.
#print("New tine line!")
#print("p0 == "+str(p0))
#print("p1 == "+str(p1))
#p0 =
# Now process the tine line.
process_tine(drops, p0, p1)
new_tine = False
p0 = None
p1 = None
time.sleep(0.01) # No need to draw faster than that
turtle.update()
t.clear()
#time.sleep(5) # Wait for a bit for the user to see the result...
return
if __name__=="__main__":
# Main program entry point.
main_loop()
exit(0)
Now let’s implement the process_tine function!
Ok, so what do we need for the formula?
I think we need these:
def tine(self, a, l, A, M) -> None: # This method applies the tine line transformation to this ink drop
# "a" and "l" are both user defined parameters.
# These are calculated from the mouse clicks:
# A is the point on the line.
# M is the unit vector in the direction of the line.
return # Just a stub for now.
Ok, so let’s craft these arguments.
Maybe something like this????
def process_tine(drops, p0, p1) -> None: # This applies the tine transformation to each of the drops.
#return # Just a stub for now.
A = p0 # Just set A to the first point.
p0_to_p1 = tuple((-p0[0]+p1[0], -p0[1]+p1[1]))
# Divide by magnitude to get unit vec.
mag = math.sqrt(p0_to_p1[0]**2 + p0_to_p1[1]**2) # Magnitude of the vector...
# Now divide...
unit_vec = tuple((p0_to_p1[0]/mag, p0_to_p1[1]/mag))
M = unit_vec
# Let's set alpha and lambda to just some constants.
a = 0.1 # alpha
l = 0.1 # lambda
# def tine(self, a, l, A, M) -> None:
# Now call tine() on each of the drop objects.
for drop in drops:
drop.tine(a, l, A, M)
return
now let’s code the method for the ink drop object:
def tine(self, a, l, A, M) -> None: # This method applies the tine line transformation to this ink drop
# "a" and "l" are both user defined parameters.
# These are calculated from the mouse clicks:
# A is the point on the line.
# M is the unit vector in the direction of the line.
#return # Just a stub for now.
# "N is a unit vector perpendicular to L"
# Let's calculate value of N
# perpendicular
N = perpendicular(M) # M is a unit vector in the direction of the line, so therefore we can just call "perpendicular" on it.
for i in range(len(self.vertices)): # Loop through all points.
P = self.vertices[i]
d = calc_d(P, A, N)
scalar_frac = (a * l) / (d + l)
thing = tuple((M[0]*scalar_frac, M[1]*scalar_frac))
self.vertices[i] = tuple((self.vertices[i][0] + thing[0], self.vertices[i][1] + thing[1]))
return
def perpendicular(a):
#b = np.empty_like(a)
b = [0.0, 0.0]
b[0] = -a[1]
b[1] = a[0]
return tuple(b)
and it seems to work okay. One issue with this, is that it also moves the entire inkdrop in addition to drawing a streak through it, but maybe that is just a feature and not a bug maybe???? idk..
Okay, so now after completing this, I actually think that this was quite fun to try to implement and do. You can check out my github repo here: https://github.com/personnumber3377/paper_marbling_algorithm