In [1]:
import brushcue

ctx = brushcue.Context()


HOME = "/home/dito/dev/monorepo/writing/graphics/chapters/curves/assets/cardinal-cubic"

POINTS = [
    (-3.0,  0.0),
    (-2.0,  0.0),
    (-1.0,  0.0),
    (-0.25, 1.0),
    ( 0.25,-1.0),
    ( 1.0,  0.0),
    ( 2.0,  0.0),
    ( 3.0,  0.0),
]
SCALE = 200

# Fully transparent brush used only to pad the composition's content bounds
# vertically, so the crop below stays consistent across tensions.
INVISIBLE_RENDER_STYLE = brushcue.RenderStyle.brush_only(
    brushcue.Brush.solid(
        brushcue.ProfiledColor.from_rgba_srgb(
            brushcue.RGBAColor.from_components(0, 0, 0, 0)
        ),
        0.001
    )
)
In [2]:
# Overlay every tension from TENSION_POINTS on a single graph, so the effect
# of tension can be compared directly on the same set of control points.
OVERLAY_TENSION_COLORS = [
    (-1.0, (0.0824, 0.3961, 0.7529)),  # vivid blue
    (-0.5, (0.0,    0.5373, 0.4824)),  # vivid teal
    ( 0.0, (0.1804, 0.4902, 0.1961)),  # vivid green
    ( 0.5, (0.9765, 0.6588, 0.1451)),  # vivid amber
    ( 1.0, (0.7765, 0.1569, 0.1569)),  # vivid red
]
POINT_COLOR = (0.15, 0.15, 0.15)
OVERLAY_SCALE = SCALE * 2
# Small margin (local units) beyond the outermost points, just enough to
# clear the point radius, so the crop cuts off right before the first/last dot.
X_MARGIN = 0.15

def run_overlay(key):
    scale_transform = brushcue.Transform2.to_list(
        brushcue.Transform2.identity().scale(brushcue.Vector2f.from_components(
            OVERLAY_SCALE, OVERLAY_SCALE
        ))
    )

    painter = brushcue.Painter.new()
    for tension, color in OVERLAY_TENSION_COLORS:
        path = brushcue.Path.new()
        path = path.move_to_point(brushcue.Point2f.from_components(
            POINTS[0][0],
            POINTS[0][1]
        ))
        for point in POINTS[1:]:
            path = path.cardinal_cubic_to_point(
                brushcue.Point2f.from_components(
                    point[0],
                    point[1]
                ),
                tension
            )
        line_render_style = brushcue.RenderStyle.brush_only(
            brushcue.Brush.solid(
                brushcue.ProfiledColor.from_rgba_srgb(
                    brushcue.RGBAColor.from_components(*color, 1)
                ),
                0.006
            )
        )
        painter = painter.add_path_with_render_style(path, line_render_style, scale_transform)

    point_render_style = brushcue.RenderStyle.fill_only(
        brushcue.Fill.solid(
            brushcue.ProfiledColor.from_rgba_srgb(
                brushcue.RGBAColor.from_components(*POINT_COLOR, 1)
            ),
        )
    )
    for point in POINTS:
        painter = painter.add_ellipse_with_render_style(
            brushcue.Point2f.from_components(point[0], point[1]),
            brushcue.Vector2f.from_components(0.1, 0.1),
            0,
            point_render_style,
            scale_transform
        )

    bounds_path = brushcue.Path.new()
    bounds_path = bounds_path.move_to_point(brushcue.Point2f.from_components(0.0, -1.5))
    bounds_path = bounds_path.line_to_point(brushcue.Point2f.from_components(0.0, 1.5))
    painter = painter.add_path_with_render_style(bounds_path, INVISIBLE_RENDER_STYLE, scale_transform)

    composition = brushcue.Composition.painter(painter)
    xs = [point[0] for point in POINTS]
    x_min = min(xs) - X_MARGIN
    x_max = max(xs) + X_MARGIN
    composition = composition.crop(
        brushcue.Bounds2f.from_x_y_width_height(
            x_min * OVERLAY_SCALE,
            -OVERLAY_SCALE * 1.1,
            (x_max - x_min) * OVERLAY_SCALE,
            OVERLAY_SCALE * 2 * 1.1 * 1.1,
        )
    )
    result = composition.execute(ctx)
    output_bytes = result.to_image_bytes(ctx)
    output_path = f"{HOME}/{key}.png"
    with open(output_path, "wb") as f:
        f.write(output_bytes)

run_overlay("cardinal_cubic_overlay")
In [3]:
# A short line swatch per tension color, for use as a legend/key alongside
# the overlay chart.
KEY_NAMES = {
    -1.0: "cardinal_cubic_neg_1_key",
    -0.5: "cardinal_cubic_neg_05_key",
     0.0: "cardinal_cubic_0_key",
     0.5: "cardinal_cubic_05_key",
     1.0: "cardinal_cubic_1_key",
}

def run_key(key, color):
    path = brushcue.Path.new()
    path = path.move_to_point(brushcue.Point2f.from_components(-0.5, 0.0))
    path = path.line_to_point(brushcue.Point2f.from_components(0.5, 0.0))

    line_render_style = brushcue.RenderStyle.brush_only(
        brushcue.Brush.solid(
            brushcue.ProfiledColor.from_rgba_srgb(
                brushcue.RGBAColor.from_components(*color, 1)
            ),
            0.02
        )
    )
    scale_transform = brushcue.Transform2.to_list(
        brushcue.Transform2.identity().scale(brushcue.Vector2f.from_components(
            SCALE, SCALE
        ))
    )

    painter = brushcue.Painter.new()
    painter = painter.add_path_with_render_style(path, line_render_style, scale_transform)

    composition = brushcue.Composition.painter(painter)
    composition = composition.crop(
        brushcue.Bounds2f.from_x_y_width_height(
            -SCALE * 0.6,
            -SCALE * 0.1,
            SCALE * 1.2,
            SCALE * 0.2,
        )
    )
    result = composition.execute(ctx)
    output_bytes = result.to_image_bytes(ctx)
    output_path = f"{HOME}/{key}.png"
    with open(output_path, "wb") as f:
        f.write(output_bytes)

for tension, color in OVERLAY_TENSION_COLORS:
    run_key(KEY_NAMES[tension], color)