Skip to content

Working with Sketches

Manipulate sketches programmatically for advanced drawing control.

Objectives

By the end of this tutorial, you will:

  • Understand the Sketch and Stroke data structures
  • Create sketches programmatically
  • Modify and combine sketches
  • Optimize stroke order
  • Save and load sketches

Prerequisites

  • pib3 installed: pip install git+https://github.com/mamrehn/pib3.git
  • Basic understanding of 2D coordinates

Understanding Sketches

A Sketch is a collection of Strokes. Each Stroke is a continuous line (pen-down motion) represented as a series of 2D points.

Sketch
├── Stroke 1: [(0.1, 0.1), (0.2, 0.1), (0.2, 0.2)]  # L-shape
├── Stroke 2: [(0.5, 0.5), (0.6, 0.5)]               # Short line
└── Stroke 3: [(0.3, 0.3), (0.3, 0.4), (0.4, 0.4), (0.3, 0.3)]  # Triangle (closed)

Coordinates are normalized to the range [0, 1]:

  • (0, 0) = top-left corner
  • (1, 1) = bottom-right corner

Creating Strokes

From Point Arrays

import numpy as np
from pib3 import Stroke

# Create a stroke from a list of points
points = [
    [0.1, 0.1],
    [0.5, 0.1],
    [0.5, 0.5],
    [0.1, 0.5],
]
stroke = Stroke(points=points, closed=True)  # Closed loop

print(f"Points: {len(stroke)}")
print(f"Length: {stroke.length():.3f}")
print(f"Closed: {stroke.closed}")
print(f"Start: {stroke.start()}")
print(f"End: {stroke.end()}")

Creating Common Shapes

import numpy as np
from pib3 import Stroke

def create_rectangle(x, y, width, height):
    """Create a rectangle stroke."""
    return Stroke(
        points=[
            [x, y],
            [x + width, y],
            [x + width, y + height],
            [x, y + height],
        ],
        closed=True,
    )

def create_circle(cx, cy, radius, num_points=32):
    """Create a circle stroke."""
    angles = np.linspace(0, 2 * np.pi, num_points, endpoint=False)
    points = [[cx + radius * np.cos(a), cy + radius * np.sin(a)] for a in angles]
    return Stroke(points=points, closed=True)

def create_line(x1, y1, x2, y2):
    """Create a line stroke."""
    return Stroke(points=[[x1, y1], [x2, y2]], closed=False)

# Use them
rect = create_rectangle(0.1, 0.1, 0.3, 0.2)
circle = create_circle(0.5, 0.5, 0.2)
line = create_line(0.1, 0.9, 0.9, 0.9)

Creating Sketches

From Strokes

from pib3 import Sketch, Stroke

# Create individual strokes
stroke1 = Stroke(points=[[0.1, 0.1], [0.9, 0.1]])
stroke2 = Stroke(points=[[0.1, 0.5], [0.9, 0.5]])
stroke3 = Stroke(points=[[0.1, 0.9], [0.9, 0.9]])

# Combine into a sketch
sketch = Sketch(strokes=[stroke1, stroke2, stroke3])

print(f"Strokes: {len(sketch)}")
print(f"Total points: {sketch.total_points()}")
print(f"Total length: {sketch.total_length():.3f}")

Adding Strokes Dynamically

from pib3 import Sketch, Stroke

sketch = Sketch()

# Add strokes one by one
sketch.add_stroke(Stroke(points=[[0.2, 0.2], [0.8, 0.2]]))
sketch.add_stroke(Stroke(points=[[0.2, 0.8], [0.8, 0.8]]))

print(f"Now has {len(sketch)} strokes")

Inspecting Sketches

Basic Properties

from pib3 import image_to_sketch

sketch = image_to_sketch("drawing.png")

# Overall statistics
print(f"Number of strokes: {len(sketch)}")
print(f"Total points: {sketch.total_points()}")
print(f"Total path length: {sketch.total_length():.3f}")

# Bounding box (min_u, min_v, max_u, max_v)
bounds = sketch.bounds()
print(f"Bounds: {bounds}")

# Source image size (if from image)
if sketch.source_size:
    print(f"Source image: {sketch.source_size[0]}x{sketch.source_size[1]}")

Iterating Over Strokes

# Iterate directly
for stroke in sketch:
    print(f"Stroke: {len(stroke)} points, length={stroke.length():.3f}")

# Access by index
first_stroke = sketch[0]
last_stroke = sketch[-1]

# Get all strokes as list
all_strokes = list(sketch)

Modifying Sketches

Reversing Stroke Direction

from pib3 import Stroke

stroke = Stroke(points=[[0, 0], [1, 0], [1, 1]])
reversed_stroke = stroke.reverse()

print(f"Original end: {stroke.end()}")
print(f"Reversed end: {reversed_stroke.end()}")

Filtering Strokes

from pib3 import Sketch, image_to_sketch

sketch = image_to_sketch("drawing.png")

# Filter out short strokes
min_length = 0.05  # Minimum 5% of drawing area

filtered_strokes = [s for s in sketch if s.length() >= min_length]
filtered_sketch = Sketch(strokes=filtered_strokes, source_size=sketch.source_size)

print(f"Original: {len(sketch)} strokes")
print(f"Filtered: {len(filtered_sketch)} strokes")

Scaling and Transforming

import numpy as np
from pib3 import Stroke, Sketch

def scale_sketch(sketch, scale_x, scale_y):
    """Scale all strokes in a sketch."""
    new_strokes = []
    for stroke in sketch:
        scaled_points = stroke.points.copy()
        scaled_points[:, 0] *= scale_x
        scaled_points[:, 1] *= scale_y
        new_strokes.append(Stroke(points=scaled_points, closed=stroke.closed))
    return Sketch(strokes=new_strokes, source_size=sketch.source_size)

def translate_sketch(sketch, dx, dy):
    """Move all strokes in a sketch."""
    new_strokes = []
    for stroke in sketch:
        translated = stroke.points + np.array([dx, dy])
        new_strokes.append(Stroke(points=translated, closed=stroke.closed))
    return Sketch(strokes=new_strokes, source_size=sketch.source_size)

# Example: Center and scale
sketch = image_to_sketch("drawing.png")
centered = translate_sketch(sketch, 0.1, 0.1)
scaled = scale_sketch(centered, 0.8, 0.8)

Combining Sketches

from pib3 import Sketch, image_to_sketch

# Load multiple images
sketch1 = image_to_sketch("part1.png")
sketch2 = image_to_sketch("part2.png")

# Combine all strokes
combined_strokes = list(sketch1) + list(sketch2)
combined = Sketch(strokes=combined_strokes)

print(f"Combined: {len(combined)} strokes")

Optimizing Stroke Order

Reorder strokes to minimize pen travel between them:

from pib3 import image_to_sketch, ImageConfig

# Enable path optimization during image processing
config = ImageConfig(
    optimize_path_order=True  # Uses nearest-neighbor algorithm
)
sketch = image_to_sketch("drawing.png", config)

Manual Reordering

from pib3 import Sketch

def optimize_order_greedy(sketch):
    """Reorder strokes using greedy nearest-neighbor."""
    import numpy as np

    if len(sketch) <= 1:
        return sketch

    remaining = list(sketch.strokes)
    ordered = [remaining.pop(0)]

    while remaining:
        current_end = ordered[-1].end()

        # Find nearest stroke
        min_dist = float('inf')
        min_idx = 0
        reverse = False

        for i, stroke in enumerate(remaining):
            # Distance to start
            d_start = np.linalg.norm(stroke.start() - current_end)
            # Distance to end (would reverse)
            d_end = np.linalg.norm(stroke.end() - current_end)

            if d_start < min_dist:
                min_dist = d_start
                min_idx = i
                reverse = False
            if d_end < min_dist:
                min_dist = d_end
                min_idx = i
                reverse = True

        next_stroke = remaining.pop(min_idx)
        if reverse:
            next_stroke = next_stroke.reverse()
        ordered.append(next_stroke)

    return Sketch(strokes=ordered, source_size=sketch.source_size)

# Use it
optimized = optimize_order_greedy(sketch)

Saving and Loading Sketches

To/From Dictionary (JSON-serializable)

import json
from pib3 import Sketch, image_to_sketch

# Convert to dictionary
sketch = image_to_sketch("drawing.png")
data = sketch.to_dict()

# Save to JSON
with open("my_sketch.json", "w") as f:
    json.dump(data, f, indent=2)

# Load from JSON
with open("my_sketch.json") as f:
    loaded_data = json.load(f)
sketch = Sketch.from_dict(loaded_data)

Dictionary Format

{
  "strokes": [
    {
      "points": [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5]],
      "closed": false
    },
    {
      "points": [[0.3, 0.3], [0.7, 0.3], [0.7, 0.7], [0.3, 0.7]],
      "closed": true
    }
  ],
  "source_size": [800, 600]
}

Complete Example: Custom Drawing Generator

"""
Generate a custom sketch programmatically and convert to trajectory.
"""
import numpy as np
import pib3
from pib3 import Sketch, Stroke

def create_star(cx, cy, outer_r, inner_r, points=5):
    """Create a star shape."""
    angles = np.linspace(0, 2 * np.pi, points * 2, endpoint=False)
    radii = [outer_r if i % 2 == 0 else inner_r for i in range(points * 2)]
    coords = [
        [cx + r * np.sin(a), cy + r * np.cos(a)]
        for a, r in zip(angles, radii)
    ]
    return Stroke(points=coords, closed=True)

def create_spiral(cx, cy, start_r, end_r, turns=3, points_per_turn=20):
    """Create a spiral shape."""
    total_points = int(turns * points_per_turn)
    angles = np.linspace(0, turns * 2 * np.pi, total_points)
    radii = np.linspace(start_r, end_r, total_points)
    coords = [
        [cx + r * np.cos(a), cy + r * np.sin(a)]
        for a, r in zip(angles, radii)
    ]
    return Stroke(points=coords, closed=False)

# Create a custom sketch
sketch = Sketch()

# Add a star in the center
sketch.add_stroke(create_star(0.5, 0.5, 0.3, 0.15))

# Add spirals in corners
sketch.add_stroke(create_spiral(0.15, 0.15, 0.02, 0.1, turns=2))
sketch.add_stroke(create_spiral(0.85, 0.15, 0.02, 0.1, turns=2))
sketch.add_stroke(create_spiral(0.15, 0.85, 0.02, 0.1, turns=2))
sketch.add_stroke(create_spiral(0.85, 0.85, 0.02, 0.1, turns=2))

# Add border
border = Stroke(
    points=[[0.05, 0.05], [0.95, 0.05], [0.95, 0.95], [0.05, 0.95]],
    closed=True,
)
sketch.add_stroke(border)

print(f"Created sketch with {len(sketch)} strokes")
print(f"Total points: {sketch.total_points()}")

# Convert to trajectory
trajectory = pib3.sketch_to_trajectory(sketch)
trajectory.to_json("custom_drawing.json")
print(f"Saved trajectory with {len(trajectory)} waypoints")

Next Steps