Skip to main content

Object moving along a trajectory

note

This section may require some Python knowledge, but don't be scared, Python is very readable and you can start by just tweaking some number parameters even without understanding the whole code.

In this example, Python is used for generating a JSON file with Rebelle Motion IO commands:

  • Set up Rebelle artwork and engine parameters
  • Paint a circle object
  • Move it along a trajectory loaded from an SVG file
  • Change brush color in each animation frame
  • Erase the "tail" painted in previous frames

To learn more about Rebelle Motion IO commands (events) and their parameteres, see the JSON Events Reference

Download project files

You can also download an example JSON output file and run it directly in Motion IO without running the python script.

Run

cd path/where/you/stored/this/example

# Generate JSON
python3 example.py

# Render animation frames with Rebelle
OUT_DIR=out
rm $OUT_DIR/*
"C:/Program Files/Rebelle 6 Motion IO/Rebelle 6 Motion IO.exe" -batch-json circle.json -batch-out-rgba $OUT_DIR/####.png

# Convert *.png files to mp4
png_size="800x600"
num_frames=$(ls "$OUT_DIR"/*.png | wc -l)
framerate=30
duration=$(echo "scale=2; $num_frames / $framerate" | bc)
ffmpeg -y -framerate $framerate -i "$OUT_DIR/%04d.png" -vf "color=white:size=${png_size} [bg]; [bg][0:v] overlay" -c:v libx264 -preset slow -crf 18 -t "$duration" "$OUT_DIR.mp4"

Main part

Let's start with the main part of the script to get a high level view of it.

# Generate JSON object
def generate_animation():
frames = []
shapes = []

# Points along the SVG path
trajectory_points = get_path_points_from_svg(TRAJECTORY_SVG_FILE, NUM_FRAMES, TRAJECTORY_FIT_BBOX)

for frame_index in range(NUM_FRAMES):
frame_events = []

# Add INIT_EVENTS in the first frame
if frame_index == 0:
frame_events += INIT_EVENTS

circle_center = trajectory_points[frame_index]
#print("circle_center[",frame_index,"]: ",circle_center)

# Set brush preset for painting
frame_events += [EVENT_BRUSH_PAINT]

# Set brush color
(h, s, v) = (115 + frame_index*3, 127, 140)
frame_events += [get_color_event(h, s, v)]

# Generate the main shape object and convert it to paint events
circle = Point(circle_center[0], circle_center[1]).buffer(CIRCLE_RADIUS, 4)
frame_events += get_draw_shape_events(circle)
shapes.append(circle)

# Erase the tail
if frame_index >= ERASER_FRAME_DISTANCE:
# Switch to the eraser brush preset
frame_events += [EVENT_BRUSH_ERASER]
# How much to enlarge original painted shapes
buf_size = 130
# Take the shape from a few frames back and subtract the one which follows it, giving us a C-like shape which will be erased
shape_tail_0 = shapes[frame_index - ERASER_FRAME_DISTANCE ].buffer(buf_size, 4)
shape_tail_1 = shapes[frame_index - ERASER_FRAME_DISTANCE + 1].buffer(buf_size, 4)
shape_diff = shape_tail_0.difference(shape_tail_1)
frame_events += get_draw_shape_events(shape_diff)

# Add a few simulation steps
frame_events += [{"event_type": "SIMULATION", "repeats": 6}]

# Add the events to the frame
frame = {"events": frame_events}
frames.append(frame)

return {"frames": frames}


# Generate the animation
animation = generate_animation()

# Save the animation to a JSON file
with open("circle.json", "w") as json_file:
json.dump(animation, json_file, indent=4)

Set up Rebelle parameters

Constants and JSON objects

NUM_FRAMES = 200 # Number of animation frames
CANVAS_WIDTH = 800
CANVAS_HEIGHT = 600
CIRCLE_RADIUS = 50 # The main object
ERASER_FRAME_DISTANCE = 8 # How many frames behind the current frame is the tail erased
TRAJECTORY_SVG_FILE = "path.svg"
TRAJECTORY_FIT_BBOX = BBox(-80, 100, 1000, 300) # The box is wider than the canvas so that the whole object enters and leaves the scene

INIT_EVENTS = [
{
# "File -> New Artwork..." dialog
"event_type": "NEW_ARTWORK",
"width": CANVAS_WIDTH,
"height": CANVAS_HEIGHT,
"units": "px",
"dpi": 200,
"paper": {
"preset": "Default/HM01 Handmade",
"color": { "r": 255, "g": 255, "b": 255 },
"deckled_edges": True,
"paper_scale": 150
}
},
{
# Visual Settings Panel
"event_type": "SET_ENGINE_PARAMS",
"absorbency": 5,
"re_wet": 5,
"texture_influence": 5,
"edge_darkening": 5,
"create_drips": True,
"drip_size": 5,
"drip_length": 5,
"impasto_depth": 5,
"gloss": 5,
"paper_texture": 5,
"paint_texture": 5
},
{
# Tilt Panel
"event_type": "SET_CANVAS_TILT",
"tilt": {
"x": 0,
"y": 1
},
"enabled": 1
},
]

# Brush for painting
EVENT_BRUSH_PAINT = {
"event_type": "SET_BRUSH",
"tool": "WATERCOLOR",
"preset": "Gouache/Gouache Filbert",
"size" : 50,
"water": 55,
"opacity": 4,
"paint_type": "PAINT",
"glaze_mode": "OPAQUE",
"color": { "r": 0, "g": 0, "b": 0 }
}

# Brush for erasing
EVENT_BRUSH_ERASER = EVENT_BRUSH_PAINT.copy()
EVENT_BRUSH_ERASER["paint_type"] = "ERASE"
EVENT_BRUSH_ERASER["opacity"] = 100
EVENT_BRUSH_ERASER["size"] = 70

# For debugging and to better see actual stroke trajectories paint with a small brush
# EVENT_BRUSH_PAINT['size'] = 4
# EVENT_BRUSH_PAINT['opacity'] = 100
# EVENT_BRUSH_ERASER["paint_type"] = "PAINT"
# EVENT_BRUSH_ERASER["size"] = 4
# EVENT_BRUSH_ERASER["color"] = {"r":255, "g":0, "b":0}

Helper functions

# Generate POINTER_* events for painting the given shape's boundary 
def get_draw_shape_events(shape):
events = []
boundary = shape.boundary
if boundary.geom_type == 'LineString':
boundary = [boundary]

pressed = False
is_first_move = True
for line_string in boundary:
coords = np.array(line_string)
x_vals = coords[:, 0]
y_vals = coords[:, 1]
for x_val, y_val in zip(x_vals, y_vals):
move_event = {
"event_type": "POINTER_MOVE",
"pos": {
"x": float(x_val),
"y": float(y_val)
}
}
if is_first_move:
is_first_move = False
press_event = move_event.copy()
press_event["event_type"] = "POINTER_PRESS"
events.append(press_event)
pressed = True
events.append(move_event)

if pressed:
# Generate POINTER_RELEASE event
release_event = events[-1].copy()
release_event["event_type"] = "POINTER_RELEASE"
events.append(release_event)
return events

# Create a SET_BRUSH_COLOR event from a given HSV color
def get_color_event(h, s, v):
(r, g, b) = colorsys.hsv_to_rgb(h/179, s/255, v/255)
#print('hsv:', (h,s,v), ' rgb:', (r,g,b))
return {"event_type": "SET_BRUSH_COLOR", "color": {"r": int(r*255), "g": int(g*255), "b": int(b*255)}}

# Get a list of points sampled from a SVG path transformed to fit inside the given bbox_fit
def get_path_points_from_svg(filename, num_samples, bbox_fit):
paths, _ = svg2paths(filename)
#for path in paths:
# print('svg paths[]:', path)
path = paths[0] # Assuming there is a single path in the SVG file
bbox_path = BBox(
# path.bbox() contains x_min, x_max, y_min, y_max
path.bbox()[0],
path.bbox()[2],
path.bbox()[1] - path.bbox()[0],
path.bbox()[3] - path.bbox()[2])
#print('svg bbox_path:', bbox_path, ' bbox_fit:', bbox_fit)

points = []
for t in range(num_samples + 1):
point = path.point(t / num_samples)
# Transform the point so that the whole path fits the bbox_fit
x = (point.real - bbox_path.x) * (bbox_fit.w / bbox_path.w) + bbox_fit.x
y = (point.imag - bbox_path.y) * (bbox_fit.h / bbox_path.h) + bbox_fit.y
#print("point:",point," -> ",x,y)
points.append([x, y])

return points